Compare commits

...

151 Commits

Author SHA1 Message Date
JustSong
e2777bf73e chore: update translations 2023-06-24 12:18:25 +08:00
JustSong
77a16e6415 docs: update README 2023-06-24 11:34:33 +08:00
JustSong
827942c8a9 docs: update README 2023-06-24 11:30:16 +08:00
JustSong
604ff20541 chore: fix translation 2023-06-24 11:17:46 +08:00
JustSong
25017219f5 ci: fix GitHub Actions config 2023-06-24 10:45:37 +08:00
JustSong
2dd4ad0e06 chore: update en.json 2023-06-24 10:22:13 +08:00
JustSong
61dc117da7 ci: rename GitHub Actions file 2023-06-24 10:12:34 +08:00
JustSong
f55647278c fix: remove tqdm dependency 2023-06-24 10:09:42 +08:00
JustSong
03c05bdb5f feat: add English support 2023-06-24 10:07:49 +08:00
JustSong
aeb1cad679 docs: update README 2023-06-24 00:19:39 +08:00
JustSong
8a4cd403fd fix: fix http status code (close #193) 2023-06-23 22:59:44 +08:00
JustSong
9ac5410d06 feat: show used quota in token table 2023-06-23 21:42:31 +08:00
JustSong
7edc2b5376 feat: able to display token billing stat via billing api (close #186) 2023-06-23 20:14:53 +08:00
JustSong
d4869dfad2 chore: use notice to show password (#107) 2023-06-23 10:42:47 +08:00
JustSong
4463224f04 feat: support automatic channel testing & balance updates (close #11, close #59) 2023-06-22 22:01:03 +08:00
JustSong
ad1049b0cf feat: support search channels by key (close #185) 2023-06-22 21:19:43 +08:00
JustSong
d0c454c78e chore: able to clear all models now 2023-06-22 20:53:21 +08:00
JustSong
fe135fd508 chore: update base url setting 2023-06-22 20:49:55 +08:00
JustSong
b090e50f72 chore: use NODE_TYPE to determine node type 2023-06-22 20:39:17 +08:00
JustSong
7497f24daa docs: update README 2023-06-22 20:19:30 +08:00
JustSong
28fb4d76af fix: disable redis on master node 2023-06-22 20:12:43 +08:00
mrhaoji
ca779e4ffa fix: fix time_test.sh (#191)
* Update time_test.sh to fix the params

修复测试脚本入参问题

* Update time_test.sh to fix the params
2023-06-22 19:53:28 +08:00
JustSong
f51c982437 fix: update time_test.sh 2023-06-22 19:36:54 +08:00
JustSong
36e681e878 chore: update time_test.sh 2023-06-22 19:25:27 +08:00
JustSong
75cd522c2c chore: add time_test.sh 2023-06-22 19:14:45 +08:00
mrhaoji
c893d04667 chore: update docker-compose.yml (#189)
去除 Redis 服务的 ports 配置,只允许 Docker Compose 启动的服务才可以访问Redis,不会暴露到宿主机上也不会和宿主机产生端口冲突;同时也提升安全性。
2023-06-22 14:49:33 +08:00
JustSong
c6717307d0 chore: update one-api.service 2023-06-22 11:37:44 +08:00
JustSong
97cdb616cd docs: update README 2023-06-22 11:17:42 +08:00
JustSong
76a3913115 chore: update docker-compose.yml 2023-06-22 11:15:01 +08:00
JustSong
00151a0124 chore: format logs 2023-06-22 10:59:01 +08:00
JustSong
b86de464b5 chore: print more logs 2023-06-22 01:12:28 +08:00
JustSong
567916bd80 fix: only master node can migrate database 2023-06-22 00:52:27 +08:00
quzard
1f3b3ca7ae fix: fix channel table's sorting problem (#188) 2023-06-21 23:42:55 +08:00
JustSong
70cffbc258 docs: update README 2023-06-21 17:51:31 +08:00
JustSong
6d961064d2 feat: do not access database before response return (close #158) 2023-06-21 17:26:26 +08:00
JustSong
ba54c71948 feat: select channel without database (#158) 2023-06-21 17:04:18 +08:00
JustSong
1932c56ea8 ci: ignore alpha version 2023-06-21 16:22:56 +08:00
JustSong
dc7bb78c74 chore: update api message 2023-06-21 15:55:00 +08:00
JustSong
853a288052 chore: update api message 2023-06-21 15:54:06 +08:00
JustSong
6536a7be62 fix: do not show dollar balance if not enabled 2023-06-21 15:45:30 +08:00
JustSong
1b5c628e66 chore: update prompt 2023-06-21 00:20:48 +08:00
JustSong
e398f470a1 feat: support custom base url for channels 2023-06-20 22:32:56 +08:00
JustSong
634099e592 fix: cors allow all headers 2023-06-20 22:04:01 +08:00
JustSong
868f0474a9 docs: update README 2023-06-20 21:14:24 +08:00
JustSong
ced9f060c7 fix: fix used amount not correct 2023-06-20 21:05:07 +08:00
JustSong
14b85318a6 fix: fix crash when Redis is enabled 2023-06-20 20:56:35 +08:00
JustSong
b179c2f208 feat: able to display quota in dollar 2023-06-20 20:09:17 +08:00
JustSong
3d76a974d1 feat: use cache to avoid database access (#158) 2023-06-20 19:09:49 +08:00
JustSong
4250064296 docs: update README 2023-06-19 16:20:08 +08:00
JustSong
868d9a87d2 chore: update description 2023-06-19 16:04:35 +08:00
JustSong
33846ce4f6 refactor: rename function 2023-06-19 15:00:22 +08:00
JustSong
e5ac80c15d docs: update README 2023-06-19 11:04:17 +08:00
JustSong
9291b5fb20 docs: update README 2023-06-19 10:56:54 +08:00
JustSong
d97f1df3c9 docs: update README 2023-06-19 10:55:38 +08:00
JustSong
f0434c810c docs: update README 2023-06-19 10:53:32 +08:00
JustSong
f6fe34676f refactor: split the relay controller 2023-06-19 10:28:55 +08:00
JustSong
5c18c559c3 chore: make the user unable to search token by id 2023-06-19 10:04:38 +08:00
JustSong
75545a1f47 refactor: make operation settings separated from system settings 2023-06-19 09:53:56 +08:00
JustSong
72ea805f84 chore: hide token id for user 2023-06-19 09:13:38 +08:00
JustSong
0e35050b8b docs: update README 2023-06-19 09:10:37 +08:00
JustSong
24a4b323eb chore: set the fallback model ratio to 30 2023-06-18 22:28:28 +08:00
JustSong
aa0a9f2262 fix: fix wrong field name in SQL 2023-06-18 22:07:40 +08:00
JustSong
4010164db1 docs: update README 2023-06-18 22:03:23 +08:00
JustSong
dc94765d32 fix: do not print response to console 2023-06-18 00:22:22 +08:00
JustSong
1cb1f727c0 fix: fix the shared field is modified 2023-06-18 00:20:06 +08:00
JustSong
d97640374c feat: able to add chat page link now (close #70) 2023-06-17 23:51:56 +08:00
JustSong
ba89abedf0 docs: update README 2023-06-17 23:18:27 +08:00
JustSong
a680b1b8b7 docs: update issue template 2023-06-17 22:57:46 +08:00
JustSong
b3b7d0a0ea docs: update README 2023-06-17 22:56:12 +08:00
JustSong
8e805e23bc chore: update prompt 2023-06-17 19:33:25 +08:00
JustSong
bcbfacc04a fix: reduce the table size (close #174) 2023-06-17 19:23:25 +08:00
JustSong
5531e21526 docs: update README (close #175) 2023-06-17 19:08:13 +08:00
JustSong
c5837c3bb7 feat: support aff now (close #75) 2023-06-17 18:12:58 +08:00
JustSong
eb70b84665 chore: update api endpoint for CloseAI 2023-06-17 15:54:14 +08:00
JustSong
a909972313 fix: limit the length of email 2023-06-17 15:44:04 +08:00
JustSong
6855d0dc39 chore: add x-requested-with header in CORS setting 2023-06-17 15:30:14 +08:00
Kidultx
a43b1e2add feat: support API2GPT platform (#173)
* support API2GPT platform

* chore: update balance renderer

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-17 15:20:51 +08:00
Miniers
46c43396d8 feat: add token name to log (#172)
* add token name to log

* chore: update expression

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-17 14:56:03 +08:00
JustSong
6dcffca065 docs: update README 2023-06-17 12:55:48 +08:00
JustSong
d754620ef7 docs: update README 2023-06-17 12:07:58 +08:00
JustSong
21111126a2 docs: update README 2023-06-17 11:30:31 +08:00
JustSong
d91e7dcfdc docs: update README 2023-06-17 11:24:32 +08:00
JustSong
d79289ccdd fix: fix footer not updated asap 2023-06-17 11:03:01 +08:00
Leslie Leung
f89f6c7fa6 docs: deploy to Zeabur (#170)
* docs: deploy to Zeabur

* docs: deploy to Zeabur

* docs: deploy to Zeabur

* docs: update README

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-17 10:19:13 +08:00
Joe
b7d71b4f0a feat: support update AIProxy balance (#171)
* Add: support update AIProxy balance

* fix auth header

* chore: update balance renderer

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-17 10:08:04 +08:00
JustSong
70ed126ccb feat: return a not found response if requested a wrong API endpoints 2023-06-17 09:46:07 +08:00
JustSong
57b213a035 docs: update README 2023-06-16 16:40:54 +08:00
JustSong
549e944b95 docs: update README 2023-06-16 16:40:07 +08:00
JustSong
0cdab80a6e feat: record channel's used quota (close #137) 2023-06-16 16:02:00 +08:00
JustSong
760183a970 feat: record used quota & request count (close #102, #165) 2023-06-16 15:20:06 +08:00
JustSong
58fb18aace fix: do not record completion ratio anymore 2023-06-16 14:24:16 +08:00
张城铭
630156dc0a fix: the prompt field can be array type now (close #166, #167)
* fix: the prompt field can be array type now (close #166)

* fix: fix prompt type

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-16 14:20:25 +08:00
JustSong
5f23f59d1c docs: update notice 2023-06-16 00:24:38 +08:00
JustSong
538a5d7a9b docs: update issue template (#164) 2023-06-15 21:51:30 +08:00
JustSong
593e1926e9 feat: able to disable quota consumption recording (close #156) 2023-06-15 16:32:16 +08:00
quzard
e87ad1f402 chore: remove -0613 suffix for Azure (#163) 2023-06-14 16:33:03 +08:00
JustSong
07cccdc8c0 docs: update issue template 2023-06-14 15:13:05 +08:00
JustSong
f71f01662c docs: update issue template 2023-06-14 15:03:51 +08:00
JustSong
54d7a1c2e8 docs: update issue template 2023-06-14 15:02:36 +08:00
JustSong
f426f31bd7 docs: update issue template 2023-06-14 14:59:24 +08:00
JustSong
2930577cd6 docs: update issue template 2023-06-14 14:51:48 +08:00
JustSong
e09512177a docs: add issue templates 2023-06-14 14:48:31 +08:00
JustSong
d6dbaff3c2 fix: fix file not committed 2023-06-14 12:52:56 +08:00
JustSong
7f9577a386 feat: now one channel can belong to multiple groups (close #153) 2023-06-14 12:14:08 +08:00
JustSong
38668e7331 chore: update gpt3.5 completion ratio 2023-06-14 09:41:06 +08:00
JustSong
323f3d263a feat: add new released models 2023-06-14 09:12:14 +08:00
JustSong
0c34ed4c61 docs: update README 2023-06-13 17:45:01 +08:00
JustSong
7c7eb6b7ec fix: now the input field can be array type now (close #149) 2023-06-12 16:11:57 +08:00
JustSong
8b2ef666ef fix: fix OpenAI-SB balance not correct 2023-06-12 09:40:49 +08:00
JustSong
955d5f8707 fix: fix group list not correct (close #147) 2023-06-12 09:11:48 +08:00
quzard
47ca449e32 feat: add support for updating balance of channel typpe OpenAI-SB (#146, close #125)
* Add support for updating channel balance in OpenAISB

* fix: handel error

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-11 21:04:41 +08:00
JustSong
39481eb6c0 chore: add trailing slash for API calling 2023-06-11 16:33:40 +08:00
JustSong
69153e7231 docs: update README 2023-06-11 12:37:15 +08:00
JustSong
cdef10cad8 docs: update README 2023-06-11 11:11:47 +08:00
JustSong
077853416d chore: record ratio detail in log 2023-06-11 11:11:19 +08:00
JustSong
596446dba4 feat: able to set group ratio now (close #62, close #142) 2023-06-11 11:08:16 +08:00
JustSong
9d0bec83df chore: update prompt 2023-06-11 09:55:50 +08:00
JustSong
f97a9ce597 fix: correct OpenAI error code's type 2023-06-11 09:49:57 +08:00
JustSong
4339f45f74 feat: support /v1/moderations now (close #117) 2023-06-11 09:37:36 +08:00
JustSong
e398e0756b docs: update README 2023-06-10 20:43:32 +08:00
JustSong
64db39320a feat: now able to check all user's log 2023-06-10 20:40:23 +08:00
JustSong
0b4bf30908 docs: update README 2023-06-10 16:34:14 +08:00
JustSong
d29c273073 chore: add more log types 2023-06-10 16:31:40 +08:00
JustSong
74f508e847 feat: now user can check its topup & consume history (close #78, close #95) 2023-06-10 16:04:04 +08:00
JustSong
145bb14cb2 fix: fix not using proxy when update balance 2023-06-09 18:57:27 +08:00
JustSong
8901f03864 feat: support set proxy for channel OpenAI (close #139) 2023-06-09 18:30:01 +08:00
JustSong
813bf0bd66 refactor: enable model configuration on default group (close #143) 2023-06-09 18:05:51 +08:00
JustSong
45e9fd66e7 feat: able to check topup history & consumption history (#78, #95) 2023-06-09 16:59:00 +08:00
JustSong
e0d0674f81 fix: fix redemption code's quota not updated 2023-06-08 15:19:55 +08:00
JustSong
4b6adaec0b feat: support /v1/completions (close #115) 2023-06-08 14:54:02 +08:00
JustSong
9301b3fed3 chore: update test logic 2023-06-08 14:09:39 +08:00
JustSong
c6edb78ac9 docs: update README 2023-06-08 09:44:47 +08:00
JustSong
521ede2469 fix: able to manage root user now 2023-06-08 09:28:06 +08:00
JustSong
2c53424db8 feat: able to manage group now 2023-06-08 09:26:54 +08:00
JustSong
2ad22e1425 feat: support group now (close #17, close #72, close #85, close #104, close #136)
Co-authored-by: quzard <1191890118@qq.com>
2023-06-07 23:26:00 +08:00
JustSong
502515bbbd docs: update README 2023-06-07 15:31:18 +08:00
JustSong
1e1c6a828f fix: prompt user the feat is not implemented (#125) 2023-06-03 11:09:14 +08:00
JustSong
2847a08852 feat: the format of key is now constant with that of OpenAI 2023-06-03 10:53:25 +08:00
JustSong
98f1a627f0 fix: fix balance query (close #138) 2023-06-03 10:17:52 +08:00
JustSong
333e4216d2 fix: fix balance query (close #138) 2023-06-03 10:11:59 +08:00
zaunist
7e80e2da3a fix: add a blank VERSION file (#135) 2023-06-02 14:20:40 +08:00
dependabot[bot]
139624b8a4 chore: bump github.com/gin-gonic/gin from 1.9.0 to 1.9.1 (#134)
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.9.0 to 1.9.1.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.9.0...v1.9.1)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-02 13:13:36 +08:00
JustSong
2f44aaa645 chore: update config prompt (close #133) 2023-06-01 18:15:53 +08:00
quzard
0f6958c57a fix: update common.RootUserEmail when root's email changed (#132) 2023-05-31 15:51:31 +08:00
quzard
5f045f8cf5 fix: update docker-compose.yml (#131)
使用相对路径,更通用
2023-05-31 15:37:59 +08:00
quzard
f19ee05351 fix: fetch root user's email if blank (#129) 2023-05-31 15:24:40 +08:00
JustSong
fa71daa8a7 fix: fix wrong implementation for /v1/models (close #128) 2023-05-31 14:43:29 +08:00
JustSong
54215dc303 chore: make channel test related code separated 2023-05-23 10:01:09 +08:00
JustSong
f9f42997b2 chore: only check OpenAI channel & custom channel 2023-05-23 10:00:36 +08:00
JustSong
25eab0b224 style: fix UI related problems 2023-05-22 22:41:39 +08:00
JustSong
34bce5b464 style: add positive attribute to submit buttons (close #113) 2023-05-22 22:30:11 +08:00
85 changed files with 3992 additions and 887 deletions

24
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,24 @@
---
name: 报告问题
about: 使用简练详细的语言描述你遇到的问题
title: ''
labels: bug
assignees: ''
---
**例行检查**
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本
+ [ ] 我已完整查看过项目 README尤其是常见问题部分
+ [ ] 我理解并愿意跟进此 issue协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭
**问题描述**
**复现步骤**
**预期结果**
**相关截图**
如果没有的话,请删除此节。

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 项目群聊
url: https://openai.justsong.cn/
about: QQ 群828520184自动审核备注 One API
- name: 赞赏支持
url: https://iamazing.cn/page/reward
about: 请作者喝杯咖啡,以激励作者持续开发
- name: 付费部署或定制功能
url: https://openai.justsong.cn/
about: 加群后联系群主

View File

@@ -0,0 +1,18 @@
---
name: 功能请求
about: 使用简练详细的语言描述希望加入的新功能
title: ''
labels: enhancement
assignees: ''
---
**例行检查**
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本
+ [ ] 我理解并愿意跟进此 issue协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭
**功能描述**
**应用场景**

View File

@@ -0,0 +1,49 @@
name: Publish Docker image (amd64, English)
on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Save version info
run: |
git describe --tags > VERSION
- name: Translate
run: |
python ./i18n/translate.py --repository_path . --json_file_path ./i18n/en.json
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
justsong/one-api-en
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- '*'
- '!*-alpha*'
workflow_dispatch:
inputs:
name:

View File

@@ -6,6 +6,7 @@ on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: ubuntu-latest

View File

@@ -6,6 +6,7 @@ on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: macos-latest

View File

@@ -6,6 +6,7 @@ on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: windows-latest

284
README.en.md Normal file
View File

@@ -0,0 +1,284 @@
<p align="right">
<a href="./README.md">中文</a> | <strong>English</strong>
</p>
<p align="center">
<a href="https://github.com/songquanpeng/one-api"><img src="https://raw.githubusercontent.com/songquanpeng/one-api/main/web/public/logo.png" width="150" height="150" alt="one-api logo"></a>
</p>
<div align="center">
# One API
_✨ The all-in-one OpenAI interface, integrates various API access methods, ready to use ✨_
</div>
<p align="center">
<a href="https://raw.githubusercontent.com/songquanpeng/one-api/main/LICENSE">
<img src="https://img.shields.io/github/license/songquanpeng/one-api?color=brightgreen" alt="license">
</a>
<a href="https://github.com/songquanpeng/one-api/releases/latest">
<img src="https://img.shields.io/github/v/release/songquanpeng/one-api?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://hub.docker.com/repository/docker/justsong/one-api">
<img src="https://img.shields.io/docker/pulls/justsong/one-api?color=brightgreen" alt="docker pull">
</a>
<a href="https://github.com/songquanpeng/one-api/releases/latest">
<img src="https://img.shields.io/github/downloads/songquanpeng/one-api/total?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://goreportcard.com/report/github.com/songquanpeng/one-api">
<img src="https://goreportcard.com/badge/github.com/songquanpeng/one-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="#deployment">Deployment Tutorial</a>
·
<a href="#usage">Usage</a>
·
<a href="https://github.com/songquanpeng/one-api/issues">Feedback</a>
·
<a href="#screenshots">Screenshots</a>
·
<a href="https://openai.justsong.cn/">Live Demo</a>
·
<a href="#faq">FAQ</a>
·
<a href="#related-projects">Related Projects</a>
·
<a href="https://iamazing.cn/page/reward">Donate</a>
</p>
> **Warning**: This README is translated by ChatGPT. Please feel free to submit a PR if you find any translation errors.
> **Warning**: The Docker image for English version is `justsong/one-api-en`.
> **Note**: The latest image pulled from Docker may be an `alpha` release. Specify the version manually if you require stability.
## Features
1. Supports multiple API access channels. Welcome PRs or issue submissions for additional channels:
+ [x] Official OpenAI channel (support proxy configuration)
+ [x] **Azure OpenAI API**
+ [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [API2D](https://api2d.com/r/197971)
+ [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf)
+ [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (invitation code: `OneAPI`)
+ [x] [API2GPT](http://console.api2gpt.com/m/00002S)
+ [x] [CloseAI](https://console.closeai-asia.com/r/2412)
+ [x] [AI.LS](https://ai.ls)
+ [x] [OpenAI Max](https://openaimax.com)
+ [x] Custom channel: Various third-party proxy services not included in the list
2. Supports access to multiple channels through **load balancing**.
3. Supports **stream mode** that enables typewriter-like effect through stream transmission.
4. Supports **multi-machine deployment**. [See here](#multi-machine-deployment) for more details.
5. Supports **token management** that allows setting token expiration time and usage count.
6. Supports **voucher management** that enables batch generation and export of vouchers. Vouchers can be used for account balance replenishment.
7. Supports **channel management** that allows bulk creation of channels.
8. Supports **user grouping** and **channel grouping** for setting different rates for different groups.
9. Supports channel **model list configuration**.
10. Supports **quota details checking**.
11. Supports **user invite rewards**.
12. Allows display of balance in USD.
13. Supports announcement publishing, recharge link setting, and initial balance setting for new users.
14. Offers rich **customization** options:
1. Supports customization of system name, logo, and footer.
2. Supports customization of homepage and about page using HTML & Markdown code, or embedding a standalone webpage through iframe.
15. Supports management API access through system access tokens.
16. Supports Cloudflare Turnstile user verification.
17. Supports user management and multiple user login/registration methods:
+ Email login/registration and password reset via email.
+ [GitHub OAuth](https://github.com/settings/applications/new).
+ WeChat Official Account authorization (requires additional deployment of [WeChat Server](https://github.com/songquanpeng/wechat-server)).
18. Immediate support and encapsulation of other major model APIs as they become available.
## Deployment
### Docker Deployment
Deployment command: `docker run --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api-en`
Update command: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR`
The first `3000` in `-p 3000:3000` is the port of the host, which can be modified as needed.
Data will be saved in the `/home/ubuntu/data/one-api` directory on the host. Ensure that the directory exists and has write permissions, or change it to a suitable directory.
Nginx reference configuration:
```
server{
server_name openai.justsong.cn; # Modify your domain name accordingly
location / {
client_max_body_size 64m;
proxy_http_version 1.1;
proxy_pass http://localhost:3000; # Modify your port accordingly
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_cache_bypass $http_upgrade;
proxy_set_header Accept-Encoding gzip;
}
}
```
Next, configure HTTPS with Let's Encrypt certbot:
```bash
# Install certbot on Ubuntu:
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
# Generate certificates & modify Nginx configuration
sudo certbot --nginx
# Follow the prompts
# Restart Nginx
sudo service nginx restart
```
The initial account username is `root` and password is `123456`.
### Manual Deployment
1. Download the executable file from [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) or compile from source:
```shell
git clone https://github.com/songquanpeng/one-api.git
# Build the frontend
cd one-api/web
npm install
npm run build
# Build the backend
cd ..
go mod download
go build -ldflags "-s -w" -o one-api
```
2. Run:
```shell
chmod u+x one-api
./one-api --port 3000 --log-dir ./logs
```
3. Access [http://localhost:3000/](http://localhost:3000/) and log in. The initial account username is `root` and password is `123456`.
For more detailed deployment tutorials, please refer to [this page](https://iamazing.cn/page/how-to-deploy-a-website).
### Multi-machine Deployment
1. Set the same `SESSION_SECRET` for all servers.
2. Set `SQL_DSN` and use MySQL instead of SQLite. All servers should connect to the same database.
3. Set the `NODE_TYPE` for all non-master nodes to `slave`.
4. Set `SYNC_FREQUENCY` for servers to periodically sync configurations from the database.
5. Non-master nodes can optionally set `FRONTEND_BASE_URL` to redirect page requests to the master server.
6. Install Redis separately on non-master nodes, and configure `REDIS_CONN_STRING` so that the database can be accessed with zero latency when the cache has not expired.
7. If the main server also has high latency accessing the database, Redis must be enabled and `SYNC_FREQUENCY` must be set to periodically sync configurations from the database.
Please refer to the [environment variables](#environment-variables) section for details on using environment variables.
### Deployment on Control Panels (e.g., Baota)
Refer to [#175](https://github.com/songquanpeng/one-api/issues/175) for detailed instructions.
If you encounter a blank page after deployment, refer to [#97](https://github.com/songquanpeng/one-api/issues/97) for possible solutions.
### Deployment on Third-Party Platforms
<details>
<summary><strong>Deployment on Zeabur</strong></summary>
<div>
> Zeabur's servers are located overseas, automatically solving network issues, and the free quota is sufficient for personal usage.
1. First, fork the code.
2. Go to [Zeabur](https://zeabur.com/), log in, and enter the console.
3. Create a new project. In Service -> Add Service, select Marketplace, and choose MySQL. Note down the connection parameters (username, password, address, and port).
4. Copy the connection parameters and run ```create database `one-api` ``` to create the database.
5. Then, in Service -> Add Service, select Git (authorization is required for the first use) and choose your forked repository.
6. Automatic deployment will start, but please cancel it for now. Go to the Variable tab, add a `PORT` with a value of `3000`, and then add a `SQL_DSN` with a value of `<username>:<password>@tcp(<addr>:<port>)/one-api`. Save the changes. Please note that if `SQL_DSN` is not set, data will not be persisted, and the data will be lost after redeployment.
7. Select Redeploy.
8. In the Domains tab, select a suitable domain name prefix, such as "my-one-api". The final domain name will be "my-one-api.zeabur.app". You can also CNAME your own domain name.
9. Wait for the deployment to complete, and click on the generated domain name to access One API.
</div>
</details>
## Configuration
The system is ready to use out of the box.
You can configure it by setting environment variables or command line parameters.
After the system starts, log in as the `root` user to further configure the system.
## Usage
Add your API Key on the `Channels` page, and then add an access token on the `Tokens` page.
You can then use your access token to access One API. The usage is consistent with the [OpenAI API](https://platform.openai.com/docs/api-reference/introduction).
In places where the OpenAI API is used, remember to set the API Base to your One API deployment address, for example: `https://openai.justsong.cn`. The API Key should be the token generated in One API.
Note that the specific API Base format depends on the client you are using.
```mermaid
graph LR
A(User)
A --->|Request| B(One API)
B -->|Relay Request| C(OpenAI)
B -->|Relay Request| D(Azure)
B -->|Relay Request| E(Other downstream channels)
```
To specify which channel to use for the current request, you can add the channel ID after the token, for example: `Authorization: Bearer ONE_API_KEY-CHANNEL_ID`.
Note that the token needs to be created by an administrator to specify the channel ID.
If the channel ID is not provided, load balancing will be used to distribute the requests to multiple channels.
### Environment Variables
1. `REDIS_CONN_STRING`: When set, Redis will be used as the storage for request rate limiting instead of memory.
+ Example: `REDIS_CONN_STRING=redis://default:redispw@localhost:49153`
2. `SESSION_SECRET`: When set, a fixed session key will be used to ensure that cookies of logged-in users are still valid after the system restarts.
+ Example: `SESSION_SECRET=random_string`
3. `SQL_DSN`: When set, the specified database will be used instead of SQLite. Please use MySQL version 8.0.
+ Example: `SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`
4. `FRONTEND_BASE_URL`: When set, the specified frontend address will be used instead of the backend address.
+ Example: `FRONTEND_BASE_URL=https://openai.justsong.cn`
5. `SYNC_FREQUENCY`: When set, the system will periodically sync configurations from the database, with the unit in seconds. If not set, no sync will happen.
+ Example: `SYNC_FREQUENCY=60`
6. `NODE_TYPE`: When set, specifies the node type. Valid values are `master` and `slave`. If not set, it defaults to `master`.
+ Example: `NODE_TYPE=slave`
7. `CHANNEL_UPDATE_FREQUENCY`: When set, it periodically updates the channel balances, with the unit in minutes. If not set, no update will happen.
+ Example: `CHANNEL_UPDATE_FREQUENCY=1440`
8. `CHANNEL_TEST_FREQUENCY`: When set, it periodically tests the channels, with the unit in minutes. If not set, no test will happen.
+ Example: `CHANNEL_TEST_FREQUENCY=1440`
9. `REQUEST_INTERVAL`: The time interval (in seconds) between requests when updating channel balances and testing channel availability. Default is no interval.
+ Example: `POLLING_INTERVAL=5`
### Command Line Parameters
1. `--port <port_number>`: Specifies the port number on which the server listens. Defaults to `3000`.
+ Example: `--port 3000`
2. `--log-dir <log_dir>`: Specifies the log directory. If not set, the logs will not be saved.
+ Example: `--log-dir ./logs`
3. `--version`: Prints the system version number and exits.
4. `--help`: Displays the command usage help and parameter descriptions.
## Screenshots
![channel](https://user-images.githubusercontent.com/39998050/233837954-ae6683aa-5c4f-429f-a949-6645a83c9490.png)
![token](https://user-images.githubusercontent.com/39998050/233837971-dab488b7-6d96-43af-b640-a168e8d1c9bf.png)
## FAQ
1. What is quota? How is it calculated? Does One API have quota calculation issues?
+ Quota = Group multiplier * Model multiplier * (number of prompt tokens + number of completion tokens * completion multiplier)
+ The completion multiplier is fixed at 1.33 for GPT3.5 and 2 for GPT4, consistent with the official definition.
+ If it is not a stream mode, the official API will return the total number of tokens consumed. However, please note that the consumption multipliers for prompts and completions are different.
2. Why does it prompt "insufficient quota" even though my account balance is sufficient?
+ Please check if your token quota is sufficient. It is separate from the account balance.
+ The token quota is used to set the maximum usage and can be freely set by the user.
3. It says "No available channels" when trying to use a channel. What should I do?
+ Please check the user and channel group settings.
+ Also check the channel model settings.
4. Channel testing reports an error: "invalid character '<' looking for beginning of value"
+ This error occurs when the returned value is not valid JSON but an HTML page.
+ Most likely, the IP of your deployment site or the node of the proxy has been blocked by CloudFlare.
5. ChatGPT Next Web reports an error: "Failed to fetch"
+ Do not set `BASE_URL` during deployment.
+ Double-check that your interface address and API Key are correct.
## Related Projects
[FastGPT](https://github.com/c121914yu/FastGPT): Build an AI knowledge base in three minutes
## Note
This project is an open-source project. Please use it in compliance with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**. It must not be used for illegal purposes.
This project is open-sourced under the MIT license. One must somehow retain the copyright information of One API.
According to the MIT license, users should bear the risk and responsibility of using this project, and the developer of this open-source project is not responsible for this.

163
README.md
View File

@@ -1,3 +1,8 @@
<p align="right">
<strong>中文</strong> | <a href="./README.en.md">English</a>
</p>
<p align="center">
<a href="https://github.com/songquanpeng/one-api"><img src="https://raw.githubusercontent.com/songquanpeng/one-api/main/web/public/logo.png" width="150" height="150" alt="one-api logo"></a>
</p>
@@ -29,10 +34,10 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
</p>
<p align="center">
<a href="https://github.com/songquanpeng/one-api/releases">程序下载</a>
·
<a href="https://github.com/songquanpeng/one-api#部署">部署教程</a>
·
<a href="https://github.com/songquanpeng/one-api#使用方法">使用方法</a>
·
<a href="https://github.com/songquanpeng/one-api/issues">意见反馈</a>
·
<a href="https://github.com/songquanpeng/one-api#截图展示">截图展示</a>
@@ -40,43 +45,57 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
<a href="https://openai.justsong.cn/">在线演示</a>
·
<a href="https://github.com/songquanpeng/one-api#常见问题">常见问题</a>
·
<a href="https://github.com/songquanpeng/one-api#相关项目">相关项目</a>
·
<a href="https://iamazing.cn/page/reward">赞赏支持</a>
</p>
> **Warning**:从 `v0.2` 版本升级到 `v0.3` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.2-v0.3.sql)
> **Note**:使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本
> **Warning**:从 `v0.3` 版本升级到 `v0.4` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.3-v0.4.sql)。
## 功能
1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道:
+ [x] OpenAI 官方通道
+ [x] OpenAI 官方通道(支持配置代理)
+ [x] **Azure OpenAI API**
+ [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [API2D](https://api2d.com/r/197971)
+ [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf)
+ [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (邀请码:`OneAPI`
+ [x] [API2GPT](http://console.api2gpt.com/m/00002S)
+ [x] [CloseAI](https://console.closeai-asia.com/r/2412)
+ [x] [AI.LS](https://ai.ls)
+ [x] [OpenAI Max](https://openaimax.com)
+ [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [CloseAI](https://console.openai-asia.com/r/2412)
+ [x] 自定义渠道:例如使用自行搭建的 OpenAI 代理
+ [x] 自定义渠道:例如各种未收录的第三方代理服务
2. 支持通过**负载均衡**的方式访问多个渠道。
3. 支持 **stream 模式**,可以通过流式传输实现打字机效果。
4. 支持**多机部署**[详见此处](#多机部署)。
5. 支持**令牌管理**,设置令牌的过期时间和使用次数。
6. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。
7. 支持**通道管理**,批量创建通道。
8. 支持发布公告,设置充值链接,设置新用户初始额度
9. 支持丰富的**自定义**设置,
1. 支持自定义系统名称logo 以及页脚
2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入
10. 支持通过系统访问令牌访问管理 API
11. 支持用户管理,支持**多种用户登录注册方式**
8. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率
9. 支持渠道**设置模型列表**。
10. 支持**查看额度明细**
11. 支持**用户邀请奖励**
12. 支持以美元为单位显示额度
13. 支持发布公告,设置充值链接,设置新用户初始额度。
14. 支持丰富的**自定义**设置,
1. 支持自定义系统名称logo 以及页脚。
2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。
15. 支持通过系统访问令牌访问管理 API。
16. 支持 Cloudflare Turnstile 用户校验。
17. 支持用户管理,支持**多种用户登录注册方式**
+ 邮箱登录注册以及通过邮箱进行密码重置。
+ [GitHub 开放授权](https://github.com/settings/applications/new)。
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。
12. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。
18. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。
## 部署
### 基于 Docker 进行部署
执行`docker run -d --restart always -p 3000:3000 -v /home/ubuntu/data/one-api:/data justsong/one-api`
部署命令`docker run --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api`
更新命令:`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR`
`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。
@@ -111,6 +130,8 @@ sudo certbot --nginx
sudo service nginx restart
```
初始账号用户名为 `root`,密码为 `123456`
### 手动部署
1. 从 [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) 下载可执行文件或者从源码编译:
```shell
@@ -137,12 +158,62 @@ sudo service nginx restart
### 多机部署
1. 所有服务器 `SESSION_SECRET` 设置一样的值。
2. 必须设置 `SQL_DSN`,使用 MySQL 数据库而非 SQLite请自行配置主备数据库同步
3. 所有从服务器必须设置 `SYNC_FREQUENCY`,以定期从数据库同步配置
4. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器
2. 必须设置 `SQL_DSN`,使用 MySQL 数据库而非 SQLite所有服务器连接同一个数据库。
3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`
4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置
5. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器。
6. 从服务器上**分别**装好 Redis设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟。
7. 如果主服务器访问数据库延迟也比较高,则也需要启用 Redis并设置 `SYNC_FREQUENCY`,以定期从数据库同步配置。
环境变量的具体使用方法详见[此处](#环境变量)。
### 宝塔部署教程
详见 [#175](https://github.com/songquanpeng/one-api/issues/175)。
如果部署后访问出现空白页面,详见 [#97](https://github.com/songquanpeng/one-api/issues/97)。
### 部署第三方服务配合 One API 使用
> 欢迎 PR 添加更多示例。
#### ChatGPT Next Web
项目主页https://github.com/Yidadaa/ChatGPT-Next-Web
```bash
docker run --name chat-next-web -d -p 3001:3000 yidadaa/chatgpt-next-web
```
注意修改端口号和 `BASE_URL`。
#### ChatGPT Web
项目主页https://github.com/Chanzhaoyu/chatgpt-web
```bash
docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://openai.justsong.cn -e OPENAI_API_KEY=sk-xxx chenzhaoyu94/chatgpt-web
```
注意修改端口号、`OPENAI_API_BASE_URL` 和 `OPENAI_API_KEY`。
### 部署到第三方平台
<details>
<summary><strong>部署到 Zeabur</strong></summary>
<div>
> Zeabur 的服务器在国外,自动解决了网络的问题,同时免费的额度也足够个人使用。
1. 首先 fork 一份代码。
2. 进入 [Zeabur](https://zeabur.com/),登录,进入控制台。
3. 新建一个 Project在 Service -> Add Service 选择 Marketplace选择 MySQL并记下连接参数用户名、密码、地址、端口
4. 复制链接参数,运行 ```create database `one-api` ``` 创建数据库。
5. 然后在 Service -> Add Service选择 Git第一次使用需要先授权选择你 fork 的仓库。
6. Deploy 会自动开始,先取消。进入下方 Variable添加一个 `PORT`,值为 `3000`,再添加一个 `SQL_DSN`,值为 `<username>:<password>@tcp(<addr>:<port>)/one-api` ,然后保存。 注意如果不填写 `SQL_DSN`,数据将无法持久化,重新部署后数据会丢失。
7. 选择 Redeploy。
8. 进入下方 Domains选择一个合适的域名前缀如 "my-one-api",最终域名为 "my-one-api.zeabur.app",也可以 CNAME 自己的域名。
9. 等待部署完成,点击生成的域名进入 One API。
</div>
</details>
## 配置
系统本身开箱即用。
@@ -150,11 +221,24 @@ sudo service nginx restart
等到系统启动后,使用 `root` 用户登录系统并做进一步的配置。
## 使用方
在`渠道`页面中添加你的 API Key之后在`令牌`页面中新增一个访问令牌。
## 使用方
在`渠道`页面中添加你的 API Key之后在`令牌`页面中新增访问令牌。
之后就可以使用你的令牌访问 One API 了,使用方式与 [OpenAI API](https://platform.openai.com/docs/api-reference/introduction) 一致。
你需要在各种用到 OpenAI API 的地方设置 API Base 为你的 One API 的部署地址,例如:`https://openai.justsong.cn`API Key 则为你在 One API 中生成的令牌。
注意,具体的 API Base 的格式取决于你所使用的客户端。
```mermaid
graph LR
A(用户)
A --->|请求| B(One API)
B -->|中继请求| C(OpenAI)
B -->|中继请求| D(Azure)
B -->|中继请求| E(其他下游渠道)
```
可以通过在令牌后面添加渠道 ID 的方式指定使用哪一个渠道处理本次请求,例如:`Authorization: Bearer ONE_API_KEY-CHANNEL_ID`。
注意,需要是管理员用户创建的令牌才能指定渠道 ID。
@@ -165,12 +249,20 @@ sudo service nginx restart
+ 例子:`REDIS_CONN_STRING=redis://default:redispw@localhost:49153`
2. `SESSION_SECRET`:设置之后将使用固定的会话密钥,这样系统重新启动后已登录用户的 cookie 将依旧有效。
+ 例子:`SESSION_SECRET=random_string`
3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite。
+ 例子:`SQL_DSN=root:123456@tcp(localhost:3306)/one-api`
3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite,请使用 MySQL 8.0 版本
+ 例子:`SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`
4. `FRONTEND_BASE_URL`:设置之后将使用指定的前端地址,而非后端地址。
+ 例子:`FRONTEND_BASE_URL=https://openai.justsong.cn`
5. `SYNC_FREQUENCY`:设置之后将定期与数据库同步配置,单位为秒,未设置则不进行同步。
+ 例子:`SYNC_FREQUENCY=60`
6. `NODE_TYPE`:设置之后将指定节点类型,可选值为 `master` 和 `slave`,未设置则默认为 `master`。
+ 例子:`NODE_TYPE=slave`
7. `CHANNEL_UPDATE_FREQUENCY`:设置之后将定期更新渠道余额,单位为分钟,未设置则不进行更新。
+ 例子:`CHANNEL_UPDATE_FREQUENCY=1440`
8. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。
+ 例子:`CHANNEL_TEST_FREQUENCY=1440`
9. `REQUEST_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。
+ 例子:`POLLING_INTERVAL=5`
### 命令行参数
1. `--port <port_number>`: 指定服务器监听的端口号,默认为 `3000`。
@@ -190,8 +282,29 @@ https://openai.justsong.cn
![token](https://user-images.githubusercontent.com/39998050/233837971-dab488b7-6d96-43af-b640-a168e8d1c9bf.png)
## 常见问题
1. 账户额度足够为什么提示额度不足
1. 额度是什么怎么计算的One API 的额度计算有问题
+ 额度 = 分组倍率 * 模型倍率 * (提示 token 数 + 补全 token 数 * 补全倍率)
+ 其中补全倍率对于 GPT3.5 固定为 1.33GPT4 为 2与官方保持一致。
+ 如果是非流模式,官方接口会返回消耗的总 token但是你要注意提示和补全的消耗倍率不一样。
2. 账户额度足够为什么提示额度不足?
+ 请检查你的令牌额度是否足够,这个和账户额度是分开的。
+ 令牌额度仅供用户设置最大使用量,用户可自由设置。
2. 宝塔部署后访问出现空白页面
+ 自动配置的问题,详见[#97](https://github.com/songquanpeng/one-api/issues/97)
3. 提示无可用渠道
+ 请检查的用户分组和渠道分组设置
+ 以及渠道的模型设置。
4. 渠道测试报错:`invalid character '<' looking for beginning of value`
+ 这是因为返回值不是合法的 JSON而是一个 HTML 页面。
+ 大概率是你的部署站的 IP 或代理的节点被 CloudFlare 封禁了。
5. ChatGPT Next Web 报错:`Failed to fetch`
+ 部署的时候不要设置 `BASE_URL`。
+ 检查你的接口地址和 API Key 有没有填对。
## 相关项目
[FastGPT](https://github.com/c121914yu/FastGPT): 三分钟搭建 AI 知识库
## 注意
本项目为开源项目,请在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
本项目使用 MIT 协议进行开源,请以某种方式保留 One API 的版权信息。
依据 MIT 协议,使用者需自行承担使用本项目的风险与责任,本开源项目开发者与此无关。

0
VERSION Normal file
View File

View File

@@ -0,0 +1,17 @@
INSERT INTO abilities (`group`, model, channel_id, enabled)
SELECT c.`group`, m.model, c.id, 1
FROM channels c
CROSS JOIN (
SELECT 'gpt-3.5-turbo' AS model UNION ALL
SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL
SELECT 'gpt-4' AS model UNION ALL
SELECT 'gpt-4-0314' AS model
) AS m
WHERE c.status = 1
AND NOT EXISTS (
SELECT 1
FROM abilities a
WHERE a.`group` = c.`group`
AND a.model = m.model
AND a.channel_id = c.id
);

36
bin/time_test.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
if [ $# -ne 3 ]; then
echo "Usage: time_test.sh <domain> <key> <count>"
exit 1
fi
domain=$1
key=$2
count=$3
total_time=0
times=()
for ((i=1; i<=count; i++)); do
result=$(curl -o /dev/null -s -w %{time_total}\\n \
https://"$domain"/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $key" \
-d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "gpt-3.5-turbo", "stream": false, "max_tokens": 1}')
echo "$result"
total_time=$(bc <<< "$total_time + $result")
times+=("$result")
done
average_time=$(echo "scale=4; $total_time / $count" | bc)
sum_of_squares=0
for time in "${times[@]}"; do
difference=$(echo "scale=4; $time - $average_time" | bc)
square=$(echo "scale=4; $difference * $difference" | bc)
sum_of_squares=$(echo "scale=4; $sum_of_squares + $square" | bc)
done
standard_deviation=$(echo "scale=4; sqrt($sum_of_squares / $count)" | bc)
echo "Average time: $average_time±$standard_deviation"

View File

@@ -1,9 +1,12 @@
package common
import (
"github.com/google/uuid"
"os"
"strconv"
"sync"
"time"
"github.com/google/uuid"
)
var StartTime = time.Now().Unix() // unit: second
@@ -13,6 +16,10 @@ var ServerAddress = "http://localhost:3000"
var Footer = ""
var Logo = ""
var TopUpLink = ""
var ChatLink = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
var DisplayInCurrencyEnabled = true
var DisplayTokenStatEnabled = true
var UsingSQLite = false
@@ -25,6 +32,7 @@ var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex
var ItemsPerPage = 10
var MaxRecentItems = 100
var PasswordLoginEnabled = true
var PasswordRegisterEnabled = true
@@ -34,6 +42,8 @@ var WeChatAuthEnabled = false
var TurnstileCheckEnabled = false
var RegisterEnabled = true
var LogConsumeEnabled = true
var SMTPServer = ""
var SMTPPort = 587
var SMTPAccount = ""
@@ -51,6 +61,8 @@ var TurnstileSiteKey = ""
var TurnstileSecretKey = ""
var QuotaForNewUser = 0
var QuotaForInviter = 0
var QuotaForInvitee = 0
var ChannelDisableThreshold = 5.0
var AutomaticDisableChannelEnabled = false
var QuotaRemindThreshold = 1000
@@ -58,6 +70,11 @@ var PreConsumedQuota = 500
var RootUserEmail = ""
var IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
var requestInterval, _ = strconv.Atoi(os.Getenv("REQUEST_INTERVAL"))
var RequestInterval = time.Duration(requestInterval) * time.Second
const (
RoleGuestUser = 0
RoleCommonUser = 1
@@ -130,19 +147,21 @@ const (
ChannelTypeAILS = 9
ChannelTypeAIProxy = 10
ChannelTypePaLM = 11
ChannelTypeAPI2GPT = 12
)
var ChannelBaseURLs = []string{
"", // 0
"https://api.openai.com", // 1
"https://oa.api2d.net", // 2
"", // 3
"https://api.openai-asia.com", // 4
"https://api.openai-sb.com", // 5
"https://api.openaimax.com", // 6
"https://api.ohmygpt.com", // 7
"", // 8
"https://api.caipacity.com", // 9
"https://api.aiproxy.io", // 10
"", // 11
"", // 0
"https://api.openai.com", // 1
"https://oa.api2d.net", // 2
"", // 3
"https://api.openai-proxy.org", // 4
"https://api.openai-sb.com", // 5
"https://api.openaimax.com", // 6
"https://api.ohmygpt.com", // 7
"", // 8
"https://api.caipacity.com", // 9
"https://api.aiproxy.io", // 10
"", // 11
"https://api.api2gpt.com", // 12
}

26
common/gin.go Normal file
View File

@@ -0,0 +1,26 @@
package common
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"io"
)
func UnmarshalBodyReusable(c *gin.Context, v any) error {
requestBody, err := io.ReadAll(c.Request.Body)
if err != nil {
return err
}
err = c.Request.Body.Close()
if err != nil {
return err
}
err = json.Unmarshal(requestBody, &v)
if err != nil {
return err
}
// Reset request body
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return nil
}

31
common/group-ratio.go Normal file
View File

@@ -0,0 +1,31 @@
package common
import "encoding/json"
var GroupRatio = map[string]float64{
"default": 1,
"vip": 1,
"svip": 1,
}
func GroupRatio2JSONString() string {
jsonBytes, err := json.Marshal(GroupRatio)
if err != nil {
SysError("error marshalling model ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateGroupRatioByJSONString(jsonStr string) error {
GroupRatio = make(map[string]float64)
return json.Unmarshal([]byte(jsonStr), &GroupRatio)
}
func GetGroupRatio(name string) float64 {
ratio, ok := GroupRatio[name]
if !ok {
SysError("group ratio not found: " + name)
return 1
}
return ratio
}

View File

@@ -42,3 +42,11 @@ func FatalLog(v ...any) {
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
os.Exit(1)
}
func LogQuota(quota int) string {
if DisplayInCurrencyEnabled {
return fmt.Sprintf("%.6f 额度", float64(quota)/QuotaPerUnit)
} else {
return fmt.Sprintf("%d 点额度", quota)
}
}

View File

@@ -2,16 +2,23 @@ package common
import "encoding/json"
// ModelRatio
// https://platform.openai.com/docs/models/model-endpoint-compatibility
// https://openai.com/pricing
// TODO: when a new api is enabled, check the pricing here
// 1 === $0.002 / 1K tokens
var ModelRatio = map[string]float64{
"gpt-4": 15,
"gpt-4-0314": 15,
"gpt-4-0613": 15,
"gpt-4-32k": 30,
"gpt-4-32k-0314": 30,
"gpt-3.5-turbo": 1,
"gpt-3.5-turbo-0301": 1,
"gpt-4-32k-0613": 30,
"gpt-3.5-turbo": 0.75, // $0.0015 / 1K tokens
"gpt-3.5-turbo-0301": 0.75,
"gpt-3.5-turbo-0613": 0.75,
"gpt-3.5-turbo-16k": 1.5, // $0.003 / 1K tokens
"gpt-3.5-turbo-16k-0613": 1.5,
"text-ada-001": 0.2,
"text-babbage-001": 0.25,
"text-curie-001": 1,
@@ -26,27 +33,28 @@ var ModelRatio = map[string]float64{
"ada": 10,
"text-embedding-ada-002": 0.2,
"text-search-ada-doc-001": 10,
"text-moderation-stable": 10,
"text-moderation-latest": 10,
"text-moderation-stable": 0.1,
"text-moderation-latest": 0.1,
}
func ModelRatio2JSONString() string {
jsonBytes, err := json.Marshal(ModelRatio)
if err != nil {
SysError("Error marshalling model ratio: " + err.Error())
SysError("error marshalling model ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateModelRatioByJSONString(jsonStr string) error {
ModelRatio = make(map[string]float64)
return json.Unmarshal([]byte(jsonStr), &ModelRatio)
}
func GetModelRatio(name string) float64 {
ratio, ok := ModelRatio[name]
if !ok {
SysError("Model ratio not found: " + name)
return 1
SysError("model ratio not found: " + name)
return 30
}
return ratio
}

View File

@@ -17,9 +17,15 @@ func InitRedisClient() (err error) {
SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
return nil
}
if os.Getenv("SYNC_FREQUENCY") == "" {
RedisEnabled = false
SysLog("SYNC_FREQUENCY not set, Redis is disabled")
return nil
}
SysLog("Redis is enabled")
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
if err != nil {
panic(err)
FatalLog("failed to parse Redis connection string: " + err.Error())
}
RDB = redis.NewClient(opt)
@@ -27,13 +33,31 @@ func InitRedisClient() (err error) {
defer cancel()
_, err = RDB.Ping(ctx).Result()
if err != nil {
FatalLog("Redis ping test failed: " + err.Error())
}
return err
}
func ParseRedisOption() *redis.Options {
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
if err != nil {
panic(err)
FatalLog("failed to parse Redis connection string: " + err.Error())
}
return opt
}
func RedisSet(key string, value string, expiration time.Duration) error {
ctx := context.Background()
return RDB.Set(ctx, key, value, expiration).Err()
}
func RedisGet(key string) (string, error) {
ctx := context.Background()
return RDB.Get(ctx, key).Result()
}
func RedisDel(key string) error {
ctx := context.Background()
return RDB.Del(ctx, key).Err()
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/google/uuid"
"html/template"
"log"
"math/rand"
"net"
"os/exec"
"runtime"
@@ -133,6 +134,38 @@ func GetUUID() string {
return code
}
const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func init() {
rand.Seed(time.Now().UnixNano())
}
func GenerateKey() string {
rand.Seed(time.Now().UnixNano())
key := make([]byte, 48)
for i := 0; i < 16; i++ {
key[i] = keyChars[rand.Intn(len(keyChars))]
}
uuid_ := GetUUID()
for i := 0; i < 32; i++ {
c := uuid_[i]
if i%2 == 0 && c >= 'a' && c <= 'z' {
c = c - 'a' + 'A'
}
key[i+16] = c
}
return string(key)
}
func GetRandomString(length int) string {
rand.Seed(time.Now().UnixNano())
key := make([]byte, length)
for i := 0; i < length; i++ {
key[i] = keyChars[rand.Intn(len(keyChars))]
}
return string(key)
}
func GetTimestamp() int64 {
return time.Now().Unix()
}

View File

@@ -2,12 +2,22 @@ package controller
import (
"github.com/gin-gonic/gin"
"one-api/common"
"one-api/model"
)
func GetSubscription(c *gin.Context) {
userId := c.GetInt("id")
quota, err := model.GetUserQuota(userId)
var quota int
var err error
var token *model.Token
if common.DisplayTokenStatEnabled {
tokenId := c.GetInt("token_id")
token, err = model.GetTokenById(tokenId)
quota = token.RemainQuota
} else {
userId := c.GetInt("id")
quota, err = model.GetUserQuota(userId)
}
if err != nil {
openAIError := OpenAIError{
Message: err.Error(),
@@ -18,23 +28,50 @@ func GetSubscription(c *gin.Context) {
})
return
}
amount := float64(quota)
if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit
}
subscription := OpenAISubscriptionResponse{
Object: "billing_subscription",
HasPaymentMethod: true,
SoftLimitUSD: float64(quota),
HardLimitUSD: float64(quota),
SystemHardLimitUSD: float64(quota),
SoftLimitUSD: amount,
HardLimitUSD: amount,
SystemHardLimitUSD: amount,
}
c.JSON(200, subscription)
return
}
func GetUsage(c *gin.Context) {
//userId := c.GetInt("id")
// TODO: get usage from database
var quota int
var err error
var token *model.Token
if common.DisplayTokenStatEnabled {
tokenId := c.GetInt("token_id")
token, err = model.GetTokenById(tokenId)
quota = token.UsedQuota
} else {
userId := c.GetInt("id")
quota, err = model.GetUserUsedQuota(userId)
}
if err != nil {
openAIError := OpenAIError{
Message: err.Error(),
Type: "one_api_error",
}
c.JSON(200, gin.H{
"error": openAIError,
})
return
}
amount := float64(quota)
if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit
}
usage := OpenAIUsageResponse{
Object: "list",
TotalUsage: 0,
TotalUsage: amount,
}
c.JSON(200, usage)
return

View File

@@ -4,13 +4,14 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// https://github.com/songquanpeng/one-api/issues/79
@@ -37,32 +38,141 @@ type OpenAIUsageResponse struct {
TotalUsage float64 `json:"total_usage"` // unit: 0.01 dollar
}
type OpenAISBUsageResponse struct {
Msg string `json:"msg"`
Data *struct {
Credit string `json:"credit"`
} `json:"data"`
}
type AIProxyUserOverviewResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
ErrorCode int `json:"error_code"`
Data struct {
TotalPoints float64 `json:"totalPoints"`
} `json:"data"`
}
type API2GPTUsageResponse struct {
Object string `json:"object"`
TotalGranted float64 `json:"total_granted"`
TotalUsed float64 `json:"total_used"`
TotalRemaining float64 `json:"total_remaining"`
}
// GetAuthHeader get auth header
func GetAuthHeader(token string) http.Header {
h := http.Header{}
h.Add("Authorization", fmt.Sprintf("Bearer %s", token))
return h
}
func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {
client := &http.Client{}
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
for k := range headers {
req.Header.Add(k, headers.Get(k))
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
err = res.Body.Close()
if err != nil {
return nil, err
}
return body, nil
}
func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) {
url := fmt.Sprintf("https://api.openai-sb.com/sb-api/user/status?api_key=%s", channel.Key)
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := OpenAISBUsageResponse{}
err = json.Unmarshal(body, &response)
if err != nil {
return 0, err
}
if response.Data == nil {
return 0, errors.New(response.Msg)
}
balance, err := strconv.ParseFloat(response.Data.Credit, 64)
if err != nil {
return 0, err
}
channel.UpdateBalance(balance)
return balance, nil
}
func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) {
url := "https://aiproxy.io/api/report/getUserOverview"
headers := http.Header{}
headers.Add("Api-Key", channel.Key)
body, err := GetResponseBody("GET", url, channel, headers)
if err != nil {
return 0, err
}
response := AIProxyUserOverviewResponse{}
err = json.Unmarshal(body, &response)
if err != nil {
return 0, err
}
if !response.Success {
return 0, fmt.Errorf("code: %d, message: %s", response.ErrorCode, response.Message)
}
channel.UpdateBalance(response.Data.TotalPoints)
return response.Data.TotalPoints, nil
}
func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) {
url := "https://api.api2gpt.com/dashboard/billing/credit_grants"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := API2GPTUsageResponse{}
err = json.Unmarshal(body, &response)
if err != nil {
return 0, err
}
channel.UpdateBalance(response.TotalRemaining)
return response.TotalRemaining, nil
}
func updateChannelBalance(channel *model.Channel) (float64, error) {
baseURL := common.ChannelBaseURLs[channel.Type]
switch channel.Type {
case common.ChannelTypeOpenAI:
if channel.BaseURL != "" {
baseURL = channel.BaseURL
}
case common.ChannelTypeAzure:
return 0, errors.New("尚未实现")
case common.ChannelTypeCustom:
baseURL = channel.BaseURL
case common.ChannelTypeOpenAISB:
return updateChannelOpenAISBBalance(channel)
case common.ChannelTypeAIProxy:
return updateChannelAIProxyBalance(channel)
case common.ChannelTypeAPI2GPT:
return updateChannelAPI2GPTBalance(channel)
default:
return 0, errors.New("尚未实现")
}
url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL)
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return 0, err
}
auth := fmt.Sprintf("Bearer %s", channel.Key)
req.Header.Add("Authorization", auth)
res, err := client.Do(req)
if err != nil {
return 0, err
}
body, err := io.ReadAll(res.Body)
if err != nil {
return 0, err
}
err = res.Body.Close()
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
@@ -73,22 +183,12 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
}
now := time.Now()
startDate := fmt.Sprintf("%s-01", now.Format("2006-01"))
//endDate := now.Format("2006-01-02")
url = fmt.Sprintf("%s/v1/dashboard/billing/usage?start_date=%s&end_date=%s", baseURL, startDate, "2023-06-01")
req, err = http.NewRequest("GET", url, nil)
if err != nil {
return 0, err
endDate := now.Format("2006-01-02")
if !subscription.HasPaymentMethod {
startDate = now.AddDate(0, 0, -100).Format("2006-01-02")
}
req.Header.Add("Authorization", auth)
res, err = client.Do(req)
if err != nil {
return 0, err
}
body, err = io.ReadAll(res.Body)
if err != nil {
return 0, err
}
err = res.Body.Close()
url = fmt.Sprintf("%s/v1/dashboard/billing/usage?start_date=%s&end_date=%s", baseURL, startDate, endDate)
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
@@ -144,6 +244,10 @@ func updateAllChannelsBalance() error {
if channel.Status != common.ChannelStatusEnabled {
continue
}
// TODO: support Azure
if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom {
continue
}
balance, err := updateChannelBalance(channel)
if err != nil {
continue
@@ -153,6 +257,7 @@ func updateAllChannelsBalance() error {
disableChannel(channel.Id, channel.Name, "余额不足")
}
}
time.Sleep(common.RequestInterval)
}
return nil
}
@@ -173,3 +278,12 @@ func UpdateAllChannelsBalance(c *gin.Context) {
})
return
}
func AutomaticallyUpdateChannels(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("updating all channels")
_ = updateAllChannelsBalance()
common.SysLog("channels update done")
}
}

209
controller/channel-test.go Normal file
View File

@@ -0,0 +1,209 @@
package controller
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"sync"
"time"
)
func testChannel(channel *model.Channel, request ChatRequest) error {
switch channel.Type {
case common.ChannelTypeAzure:
request.Model = "gpt-35-turbo"
default:
request.Model = "gpt-3.5-turbo"
}
requestURL := common.ChannelBaseURLs[channel.Type]
if channel.Type == common.ChannelTypeAzure {
requestURL = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=2023-03-15-preview", channel.BaseURL, request.Model)
} else {
if channel.BaseURL != "" {
requestURL = channel.BaseURL
}
requestURL += "/v1/chat/completions"
}
jsonData, err := json.Marshal(request)
if err != nil {
return err
}
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
if channel.Type == common.ChannelTypeAzure {
req.Header.Set("api-key", channel.Key)
} else {
req.Header.Set("Authorization", "Bearer "+channel.Key)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var response TextResponse
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return err
}
if response.Usage.CompletionTokens == 0 {
return errors.New(fmt.Sprintf("type %s, code %v, message %s", response.Error.Type, response.Error.Code, response.Error.Message))
}
return nil
}
func buildTestRequest() *ChatRequest {
testRequest := &ChatRequest{
Model: "", // this will be set later
MaxTokens: 1,
}
testMessage := Message{
Role: "user",
Content: "hi",
}
testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest
}
func TestChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
channel, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
testRequest := buildTestRequest()
tik := time.Now()
err = testChannel(channel, *testRequest)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
go channel.UpdateResponseTime(milliseconds)
consumedTime := float64(milliseconds) / 1000.0
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"time": consumedTime,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"time": consumedTime,
})
return
}
var testAllChannelsLock sync.Mutex
var testAllChannelsRunning bool = false
// disable & notify
func disableChannel(channelId int, channelName string, reason string) {
if common.RootUserEmail == "" {
common.RootUserEmail = model.GetRootUserEmail()
}
model.UpdateChannelStatusById(channelId, common.ChannelStatusDisabled)
subject := fmt.Sprintf("通道「%s」#%d已被禁用", channelName, channelId)
content := fmt.Sprintf("通道「%s」#%d已被禁用原因%s", channelName, channelId, reason)
err := common.SendEmail(subject, common.RootUserEmail, content)
if err != nil {
common.SysError(fmt.Sprintf("failed to send email: %s", err.Error()))
}
}
func testAllChannels(notify bool) error {
if common.RootUserEmail == "" {
common.RootUserEmail = model.GetRootUserEmail()
}
testAllChannelsLock.Lock()
if testAllChannelsRunning {
testAllChannelsLock.Unlock()
return errors.New("测试已在运行中")
}
testAllChannelsRunning = true
testAllChannelsLock.Unlock()
channels, err := model.GetAllChannels(0, 0, true)
if err != nil {
return err
}
testRequest := buildTestRequest()
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
if disableThreshold == 0 {
disableThreshold = 10000000 // a impossible value
}
go func() {
for _, channel := range channels {
if channel.Status != common.ChannelStatusEnabled {
continue
}
tik := time.Now()
err := testChannel(channel, *testRequest)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
if err != nil || milliseconds > disableThreshold {
if milliseconds > disableThreshold {
err = errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
}
disableChannel(channel.Id, channel.Name, err.Error())
}
channel.UpdateResponseTime(milliseconds)
time.Sleep(common.RequestInterval)
}
testAllChannelsLock.Lock()
testAllChannelsRunning = false
testAllChannelsLock.Unlock()
if notify {
err := common.SendEmail("通道测试完成", common.RootUserEmail, "通道测试完成,如果没有收到禁用通知,说明所有通道都正常")
if err != nil {
common.SysError(fmt.Sprintf("failed to send email: %s", err.Error()))
}
}
}()
return nil
}
func TestAllChannels(c *gin.Context) {
err := testAllChannels(true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func AutomaticallyTestChannels(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("testing all channels")
_ = testAllChannels(false)
common.SysLog("channel test finished")
}
}

View File

@@ -1,18 +1,12 @@
package controller
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"strings"
"sync"
"time"
)
func GetAllChannels(c *gin.Context) {
@@ -158,187 +152,3 @@ func UpdateChannel(c *gin.Context) {
})
return
}
func testChannel(channel *model.Channel, request *ChatRequest) error {
if request.Model == "" {
request.Model = "gpt-3.5-turbo"
if channel.Type == common.ChannelTypeAzure {
request.Model = "gpt-35-turbo"
}
}
requestURL := common.ChannelBaseURLs[channel.Type]
if channel.Type == common.ChannelTypeAzure {
requestURL = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=2023-03-15-preview", channel.BaseURL, request.Model)
} else {
if channel.Type == common.ChannelTypeCustom {
requestURL = channel.BaseURL
}
requestURL += "/v1/chat/completions"
}
jsonData, err := json.Marshal(request)
if err != nil {
return err
}
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
if channel.Type == common.ChannelTypeAzure {
req.Header.Set("api-key", channel.Key)
} else {
req.Header.Set("Authorization", "Bearer "+channel.Key)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var response TextResponse
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return err
}
if response.Error.Message != "" || response.Error.Code != "" {
return errors.New(fmt.Sprintf("type %s, code %s, message %s", response.Error.Type, response.Error.Code, response.Error.Message))
}
return nil
}
func buildTestRequest(c *gin.Context) *ChatRequest {
model_ := c.Query("model")
testRequest := &ChatRequest{
Model: model_,
MaxTokens: 1,
}
testMessage := Message{
Role: "user",
Content: "hi",
}
testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest
}
func TestChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
channel, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
testRequest := buildTestRequest(c)
tik := time.Now()
err = testChannel(channel, testRequest)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
go channel.UpdateResponseTime(milliseconds)
consumedTime := float64(milliseconds) / 1000.0
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"time": consumedTime,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"time": consumedTime,
})
return
}
var testAllChannelsLock sync.Mutex
var testAllChannelsRunning bool = false
// disable & notify
func disableChannel(channelId int, channelName string, reason string) {
if common.RootUserEmail == "" {
common.RootUserEmail = model.GetRootUserEmail()
}
model.UpdateChannelStatusById(channelId, common.ChannelStatusDisabled)
subject := fmt.Sprintf("通道「%s」#%d已被禁用", channelName, channelId)
content := fmt.Sprintf("通道「%s」#%d已被禁用原因%s", channelName, channelId, reason)
err := common.SendEmail(subject, common.RootUserEmail, content)
if err != nil {
common.SysError(fmt.Sprintf("发送邮件失败:%s", err.Error()))
}
}
func testAllChannels(c *gin.Context) error {
testAllChannelsLock.Lock()
if testAllChannelsRunning {
testAllChannelsLock.Unlock()
return errors.New("测试已在运行中")
}
testAllChannelsRunning = true
testAllChannelsLock.Unlock()
channels, err := model.GetAllChannels(0, 0, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return err
}
testRequest := buildTestRequest(c)
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
if disableThreshold == 0 {
disableThreshold = 10000000 // a impossible value
}
go func() {
for _, channel := range channels {
if channel.Status != common.ChannelStatusEnabled {
continue
}
tik := time.Now()
err := testChannel(channel, testRequest)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
if err != nil || milliseconds > disableThreshold {
if milliseconds > disableThreshold {
err = errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
}
disableChannel(channel.Id, channel.Name, err.Error())
}
channel.UpdateResponseTime(milliseconds)
}
err := common.SendEmail("通道测试完成", common.RootUserEmail, "通道测试完成,如果没有收到禁用通知,说明所有通道都正常")
if err != nil {
common.SysError(fmt.Sprintf("发送邮件失败:%s", err.Error()))
}
testAllChannelsLock.Lock()
testAllChannelsRunning = false
testAllChannelsLock.Unlock()
}()
return nil
}
func TestAllChannels(c *gin.Context) {
err := testAllChannels(c)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}

View File

@@ -125,7 +125,7 @@ func GitHubOAuth(c *gin.Context) {
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
if err := user.Insert(); err != nil {
if err := user.Insert(0); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),

19
controller/group.go Normal file
View File

@@ -0,0 +1,19 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
)
func GetGroups(c *gin.Context) {
groupNames := make([]string, 0)
for groupName, _ := range common.GroupRatio {
groupNames = append(groupNames, groupName)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": groupNames,
})
}

86
controller/log.go Normal file
View File

@@ -0,0 +1,86 @@
package controller
import (
"github.com/gin-gonic/gin"
"one-api/common"
"one-api/model"
"strconv"
)
func GetAllLogs(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
}
logType, _ := strconv.Atoi(c.Query("type"))
logs, err := model.GetAllLogs(logType, p*common.ItemsPerPage, common.ItemsPerPage)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
})
}
func GetUserLogs(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
}
userId := c.GetInt("id")
logType, _ := strconv.Atoi(c.Query("type"))
logs, err := model.GetUserLogs(userId, logType, p*common.ItemsPerPage, common.ItemsPerPage)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
})
}
func SearchAllLogs(c *gin.Context) {
keyword := c.Query("keyword")
logs, err := model.SearchAllLogs(keyword)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
})
}
func SearchUserLogs(c *gin.Context) {
keyword := c.Query("keyword")
userId := c.GetInt("id")
logs, err := model.SearchUserLogs(userId, keyword)
if err != nil {
c.JSON(200, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
})
}

View File

@@ -14,20 +14,23 @@ func GetStatus(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"chat_link": common.ChatLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
},
})
return

View File

@@ -23,20 +23,21 @@ type OpenAIModelPermission struct {
}
type OpenAIModels struct {
Id string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
OwnedBy string `json:"owned_by"`
Permission OpenAIModelPermission `json:"permission"`
Root string `json:"root"`
Parent *string `json:"parent"`
Id string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
OwnedBy string `json:"owned_by"`
Permission []OpenAIModelPermission `json:"permission"`
Root string `json:"root"`
Parent *string `json:"parent"`
}
var openAIModels []OpenAIModels
var openAIModelsMap map[string]OpenAIModels
func init() {
permission := OpenAIModelPermission{
var permission []OpenAIModelPermission
permission = append(permission, OpenAIModelPermission{
Id: "modelperm-LwHkVFn8AcMItP432fKKDIKJ",
Object: "model_permission",
Created: 1626777600,
@@ -49,7 +50,7 @@ func init() {
Organization: "*",
Group: nil,
IsBlocking: false,
}
})
// https://platform.openai.com/docs/models/model-endpoint-compatibility
openAIModels = []OpenAIModels{
{
@@ -70,6 +71,33 @@ func init() {
Root: "gpt-3.5-turbo-0301",
Parent: nil,
},
{
Id: "gpt-3.5-turbo-0613",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "gpt-3.5-turbo-0613",
Parent: nil,
},
{
Id: "gpt-3.5-turbo-16k",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "gpt-3.5-turbo-16k",
Parent: nil,
},
{
Id: "gpt-3.5-turbo-16k-0613",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "gpt-3.5-turbo-16k-0613",
Parent: nil,
},
{
Id: "gpt-4",
Object: "model",
@@ -88,6 +116,15 @@ func init() {
Root: "gpt-4-0314",
Parent: nil,
},
{
Id: "gpt-4-0613",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "gpt-4-0613",
Parent: nil,
},
{
Id: "gpt-4-32k",
Object: "model",
@@ -107,12 +144,12 @@ func init() {
Parent: nil,
},
{
Id: "gpt-3.5-turbo",
Id: "gpt-4-32k-0613",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "gpt-3.5-turbo",
Root: "gpt-4-32k-0613",
Parent: nil,
},
{
@@ -124,6 +161,69 @@ func init() {
Root: "text-embedding-ada-002",
Parent: nil,
},
{
Id: "text-davinci-003",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "text-davinci-003",
Parent: nil,
},
{
Id: "text-davinci-002",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "text-davinci-002",
Parent: nil,
},
{
Id: "text-curie-001",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "text-curie-001",
Parent: nil,
},
{
Id: "text-babbage-001",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "text-babbage-001",
Parent: nil,
},
{
Id: "text-ada-001",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "text-ada-001",
Parent: nil,
},
{
Id: "text-moderation-latest",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "text-moderation-latest",
Parent: nil,
},
{
Id: "text-moderation-stable",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "text-moderation-stable",
Parent: nil,
},
}
openAIModelsMap = make(map[string]OpenAIModels)
for _, model := range openAIModels {
@@ -132,7 +232,10 @@ func init() {
}
func ListModels(c *gin.Context) {
c.JSON(200, openAIModels)
c.JSON(200, gin.H{
"object": "list",
"data": openAIModels,
})
}
func RetrieveModel(c *gin.Context) {

View File

@@ -13,7 +13,7 @@ func GetOptions(c *gin.Context) {
var options []*model.Option
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") {
if strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") {
continue
}
options = append(options, &model.Option{

34
controller/relay-image.go Normal file
View File

@@ -0,0 +1,34 @@
package controller
import (
"github.com/gin-gonic/gin"
"io"
"net/http"
)
func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
// TODO: this part is not finished
req, err := http.NewRequest(c.Request.Method, c.Request.RequestURI, c.Request.Body)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return errorWrapper(err, "do_request_failed", http.StatusOK)
}
err = req.Body.Close()
if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusOK)
}
for k, v := range resp.Header {
c.Writer.Header().Set(k, v[0])
}
c.Writer.WriteHeader(resp.StatusCode)
_, err = io.Copy(c.Writer, resp.Body)
if err != nil {
return errorWrapper(err, "copy_response_body_failed", http.StatusOK)
}
err = resp.Body.Close()
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusOK)
}
return nil
}

271
controller/relay-text.go Normal file
View File

@@ -0,0 +1,271 @@
package controller
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
"one-api/model"
"strings"
)
func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
channelType := c.GetInt("channel")
tokenId := c.GetInt("token_id")
userId := c.GetInt("id")
consumeQuota := c.GetBool("consume_quota")
group := c.GetString("group")
var textRequest GeneralOpenAIRequest
if consumeQuota || channelType == common.ChannelTypeAzure || channelType == common.ChannelTypePaLM {
err := common.UnmarshalBodyReusable(c, &textRequest)
if err != nil {
return errorWrapper(err, "bind_request_body_failed", http.StatusBadRequest)
}
}
if relayMode == RelayModeModeration && textRequest.Model == "" {
textRequest.Model = "text-moderation-latest"
}
baseURL := common.ChannelBaseURLs[channelType]
requestURL := c.Request.URL.String()
if c.GetString("base_url") != "" {
baseURL = c.GetString("base_url")
}
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
if channelType == common.ChannelTypeAzure {
// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api
query := c.Request.URL.Query()
apiVersion := query.Get("api-version")
if apiVersion == "" {
apiVersion = c.GetString("api_version")
}
requestURL := strings.Split(requestURL, "?")[0]
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
baseURL = c.GetString("base_url")
task := strings.TrimPrefix(requestURL, "/v1/")
model_ := textRequest.Model
model_ = strings.Replace(model_, ".", "", -1)
// https://github.com/songquanpeng/one-api/issues/67
model_ = strings.TrimSuffix(model_, "-0301")
model_ = strings.TrimSuffix(model_, "-0314")
model_ = strings.TrimSuffix(model_, "-0613")
fullRequestURL = fmt.Sprintf("%s/openai/deployments/%s/%s", baseURL, model_, task)
} else if channelType == common.ChannelTypePaLM {
err := relayPaLM(textRequest, c)
return err
}
var promptTokens int
switch relayMode {
case RelayModeChatCompletions:
promptTokens = countTokenMessages(textRequest.Messages, textRequest.Model)
case RelayModeCompletions:
promptTokens = countTokenInput(textRequest.Prompt, textRequest.Model)
case RelayModeModeration:
promptTokens = countTokenInput(textRequest.Input, textRequest.Model)
}
preConsumedTokens := common.PreConsumedQuota
if textRequest.MaxTokens != 0 {
preConsumedTokens = promptTokens + textRequest.MaxTokens
}
modelRatio := common.GetModelRatio(textRequest.Model)
groupRatio := common.GetGroupRatio(group)
ratio := modelRatio * groupRatio
preConsumedQuota := int(float64(preConsumedTokens) * ratio)
userQuota, err := model.CacheGetUserQuota(userId)
if err != nil {
return errorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError)
}
if userQuota > 10*preConsumedQuota {
// in this case, we do not pre-consume quota
// because the user has enough quota
preConsumedQuota = 0
}
if consumeQuota && preConsumedQuota > 0 {
err := model.PreConsumeTokenQuota(tokenId, preConsumedQuota)
if err != nil {
return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusForbidden)
}
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, c.Request.Body)
if err != nil {
return errorWrapper(err, "new_request_failed", http.StatusInternalServerError)
}
if channelType == common.ChannelTypeAzure {
key := c.Request.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
req.Header.Set("api-key", key)
} else {
req.Header.Set("Authorization", c.Request.Header.Get("Authorization"))
}
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept"))
req.Header.Set("Connection", c.Request.Header.Get("Connection"))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return errorWrapper(err, "do_request_failed", http.StatusInternalServerError)
}
err = req.Body.Close()
if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
}
err = c.Request.Body.Close()
if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusInternalServerError)
}
var textResponse TextResponse
isStream := strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream")
var streamResponseText string
defer func() {
if consumeQuota {
quota := 0
completionRatio := 1.34 // default for gpt-3
if strings.HasPrefix(textRequest.Model, "gpt-4") {
completionRatio = 2
}
if isStream {
responseTokens := countTokenText(streamResponseText, textRequest.Model)
quota = promptTokens + int(float64(responseTokens)*completionRatio)
} else {
quota = textResponse.Usage.PromptTokens + int(float64(textResponse.Usage.CompletionTokens)*completionRatio)
}
quota = int(float64(quota) * ratio)
if ratio != 0 && quota <= 0 {
quota = 1
}
quotaDelta := quota - preConsumedQuota
err := model.PostConsumeTokenQuota(tokenId, quotaDelta)
if err != nil {
common.SysError("error consuming token remain quota: " + err.Error())
}
tokenName := c.GetString("token_name")
model.RecordLog(userId, model.LogTypeConsume, fmt.Sprintf("通过令牌「%s」使用模型 %s 消耗 %s模型倍率 %.2f,分组倍率 %.2f", tokenName, textRequest.Model, common.LogQuota(quota), modelRatio, groupRatio))
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
channelId := c.GetInt("channel_id")
model.UpdateChannelUsedQuota(channelId, quota)
}
}()
if isStream {
scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := strings.Index(string(data), "\n\n"); i >= 0 {
return i + 2, data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
dataChan := make(chan string)
stopChan := make(chan bool)
go func() {
for scanner.Scan() {
data := scanner.Text()
if len(data) < 6 { // must be something wrong!
common.SysError("invalid stream response: " + data)
continue
}
dataChan <- data
data = data[6:]
if !strings.HasPrefix(data, "[DONE]") {
switch relayMode {
case RelayModeChatCompletions:
var streamResponse ChatCompletionsStreamResponse
err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return
}
for _, choice := range streamResponse.Choices {
streamResponseText += choice.Delta.Content
}
case RelayModeCompletions:
var streamResponse CompletionsStreamResponse
err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
common.SysError("error unmarshalling stream response: " + err.Error())
return
}
for _, choice := range streamResponse.Choices {
streamResponseText += choice.Text
}
}
}
}
stopChan <- true
}()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Transfer-Encoding", "chunked")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Stream(func(w io.Writer) bool {
select {
case data := <-dataChan:
if strings.HasPrefix(data, "data: [DONE]") {
data = data[:12]
}
c.Render(-1, common.CustomEvent{Data: data})
return true
case <-stopChan:
return false
}
})
err = resp.Body.Close()
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
}
return nil
} else {
if consumeQuota {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
}
err = resp.Body.Close()
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
}
err = json.Unmarshal(responseBody, &textResponse)
if err != nil {
return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError)
}
if textResponse.Error.Type != "" {
return &OpenAIErrorWithStatusCode{
OpenAIError: textResponse.Error,
StatusCode: resp.StatusCode,
}
}
// Reset response body
resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
}
// We shouldn't set the header before we parse the response body, because the parse part may fail.
// And then we will have to send an error response, but in this case, the header has already been set.
// So the client will be confused by the response.
// For example, Postman will report error, and we cannot check the response at all.
for k, v := range resp.Header {
c.Writer.Header().Set(k, v[0])
}
c.Writer.WriteHeader(resp.StatusCode)
_, err = io.Copy(c.Writer, resp.Body)
if err != nil {
return errorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError)
}
err = resp.Body.Close()
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
}
return nil
}
}

View File

@@ -58,8 +58,34 @@ func countTokenMessages(messages []Message, model string) int {
return tokenNum
}
func countTokenInput(input any, model string) int {
switch input.(type) {
case string:
return countTokenText(input.(string), model)
case []string:
text := ""
for _, s := range input.([]string) {
text += s
}
return countTokenText(text, model)
}
return 0
}
func countTokenText(text string, model string) int {
tokenEncoder := getTokenEncoder(model)
token := tokenEncoder.Encode(text, nil, nil)
return len(token)
}
func errorWrapper(err error, code string, statusCode int) *OpenAIErrorWithStatusCode {
openAIError := OpenAIError{
Message: err.Error(),
Type: "one_api_error",
Code: code,
}
return &OpenAIErrorWithStatusCode{
OpenAIError: openAIError,
StatusCode: statusCode,
}
}

View File

@@ -1,15 +1,10 @@
package controller
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
"one-api/model"
"strings"
)
@@ -19,17 +14,27 @@ type Message struct {
Name *string `json:"name,omitempty"`
}
const (
RelayModeUnknown = iota
RelayModeChatCompletions
RelayModeCompletions
RelayModeEmbeddings
RelayModeModeration
RelayModeImagesGenerations
)
// https://platform.openai.com/docs/api-reference/chat
type GeneralOpenAIRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Prompt string `json:"prompt"`
Prompt any `json:"prompt"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
N int `json:"n"`
Input any `json:"input"`
}
type ChatRequest struct {
@@ -56,7 +61,7 @@ type OpenAIError struct {
Message string `json:"message"`
Type string `json:"type"`
Param string `json:"param"`
Code string `json:"code"`
Code any `json:"code"`
}
type OpenAIErrorWithStatusCode struct {
@@ -69,7 +74,7 @@ type TextResponse struct {
Error OpenAIError `json:"error"`
}
type StreamResponse struct {
type ChatCompletionsStreamResponse struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
@@ -78,17 +83,42 @@ type StreamResponse struct {
} `json:"choices"`
}
type CompletionsStreamResponse struct {
Choices []struct {
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
func Relay(c *gin.Context) {
err := relayHelper(c)
relayMode := RelayModeUnknown
if strings.HasPrefix(c.Request.URL.Path, "/v1/chat/completions") {
relayMode = RelayModeChatCompletions
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/completions") {
relayMode = RelayModeCompletions
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/embeddings") {
relayMode = RelayModeEmbeddings
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") {
relayMode = RelayModeModeration
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
relayMode = RelayModeImagesGenerations
}
var err *OpenAIErrorWithStatusCode
switch relayMode {
case RelayModeImagesGenerations:
err = relayImageHelper(c, relayMode)
default:
err = relayTextHelper(c, relayMode)
}
if err != nil {
if err.StatusCode == http.StatusTooManyRequests {
err.OpenAIError.Message = "负载已,请稍后再试,或升级账户以提升服务质量。"
err.OpenAIError.Message = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
}
c.JSON(err.StatusCode, gin.H{
"error": err.OpenAIError,
})
channelId := c.GetInt("channel_id")
common.SysError(fmt.Sprintf("Relay error (channel #%d): %s", channelId, err.Message))
common.SysError(fmt.Sprintf("relay error (channel #%d): %s", channelId, err.Message))
// https://platform.openai.com/docs/guides/error-codes/api-errors
if common.AutomaticDisableChannelEnabled && (err.Type == "insufficient_quota" || err.Code == "invalid_api_key") {
channelId := c.GetInt("channel_id")
@@ -98,241 +128,6 @@ func Relay(c *gin.Context) {
}
}
func errorWrapper(err error, code string, statusCode int) *OpenAIErrorWithStatusCode {
openAIError := OpenAIError{
Message: err.Error(),
Type: "one_api_error",
Code: code,
}
return &OpenAIErrorWithStatusCode{
OpenAIError: openAIError,
StatusCode: statusCode,
}
}
func relayHelper(c *gin.Context) *OpenAIErrorWithStatusCode {
channelType := c.GetInt("channel")
tokenId := c.GetInt("token_id")
consumeQuota := c.GetBool("consume_quota")
var textRequest GeneralOpenAIRequest
if consumeQuota || channelType == common.ChannelTypeAzure || channelType == common.ChannelTypePaLM {
requestBody, err := io.ReadAll(c.Request.Body)
if err != nil {
return errorWrapper(err, "read_request_body_failed", http.StatusBadRequest)
}
err = c.Request.Body.Close()
if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusBadRequest)
}
err = json.Unmarshal(requestBody, &textRequest)
if err != nil {
return errorWrapper(err, "unmarshal_request_body_failed", http.StatusBadRequest)
}
// Reset request body
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
}
baseURL := common.ChannelBaseURLs[channelType]
requestURL := c.Request.URL.String()
if channelType == common.ChannelTypeCustom {
baseURL = c.GetString("base_url")
}
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
if channelType == common.ChannelTypeAzure {
// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api
query := c.Request.URL.Query()
apiVersion := query.Get("api-version")
if apiVersion == "" {
apiVersion = c.GetString("api_version")
}
requestURL := strings.Split(requestURL, "?")[0]
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
baseURL = c.GetString("base_url")
task := strings.TrimPrefix(requestURL, "/v1/")
model_ := textRequest.Model
model_ = strings.Replace(model_, ".", "", -1)
// https://github.com/songquanpeng/one-api/issues/67
model_ = strings.TrimSuffix(model_, "-0301")
model_ = strings.TrimSuffix(model_, "-0314")
fullRequestURL = fmt.Sprintf("%s/openai/deployments/%s/%s", baseURL, model_, task)
} else if channelType == common.ChannelTypePaLM {
err := relayPaLM(textRequest, c)
return err
}
promptTokens := countTokenMessages(textRequest.Messages, textRequest.Model)
preConsumedTokens := common.PreConsumedQuota
if textRequest.MaxTokens != 0 {
preConsumedTokens = promptTokens + textRequest.MaxTokens
}
ratio := common.GetModelRatio(textRequest.Model)
preConsumedQuota := int(float64(preConsumedTokens) * ratio)
if consumeQuota {
err := model.PreConsumeTokenQuota(tokenId, preConsumedQuota)
if err != nil {
return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusOK)
}
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, c.Request.Body)
if err != nil {
return errorWrapper(err, "new_request_failed", http.StatusOK)
}
if channelType == common.ChannelTypeAzure {
key := c.Request.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
req.Header.Set("api-key", key)
} else {
req.Header.Set("Authorization", c.Request.Header.Get("Authorization"))
}
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept"))
req.Header.Set("Connection", c.Request.Header.Get("Connection"))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return errorWrapper(err, "do_request_failed", http.StatusOK)
}
err = req.Body.Close()
if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusOK)
}
err = c.Request.Body.Close()
if err != nil {
return errorWrapper(err, "close_request_body_failed", http.StatusOK)
}
var textResponse TextResponse
isStream := strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream")
var streamResponseText string
defer func() {
if consumeQuota {
quota := 0
usingGPT4 := strings.HasPrefix(textRequest.Model, "gpt-4")
completionRatio := 1
if usingGPT4 {
completionRatio = 2
}
if isStream {
responseTokens := countTokenText(streamResponseText, textRequest.Model)
quota = promptTokens + responseTokens*completionRatio
} else {
quota = textResponse.Usage.PromptTokens + textResponse.Usage.CompletionTokens*completionRatio
}
quota = int(float64(quota) * ratio)
quotaDelta := quota - preConsumedQuota
err := model.PostConsumeTokenQuota(tokenId, quotaDelta)
if err != nil {
common.SysError("Error consuming token remain quota: " + err.Error())
}
}
}()
if isStream {
scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := strings.Index(string(data), "\n\n"); i >= 0 {
return i + 2, data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
dataChan := make(chan string)
stopChan := make(chan bool)
go func() {
for scanner.Scan() {
data := scanner.Text()
if len(data) < 6 { // must be something wrong!
common.SysError("Invalid stream response: " + data)
continue
}
dataChan <- data
data = data[6:]
if !strings.HasPrefix(data, "[DONE]") {
var streamResponse StreamResponse
err = json.Unmarshal([]byte(data), &streamResponse)
if err != nil {
common.SysError("Error unmarshalling stream response: " + err.Error())
return
}
for _, choice := range streamResponse.Choices {
streamResponseText += choice.Delta.Content
}
}
}
stopChan <- true
}()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Transfer-Encoding", "chunked")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Stream(func(w io.Writer) bool {
select {
case data := <-dataChan:
if strings.HasPrefix(data, "data: [DONE]") {
data = data[:12]
}
c.Render(-1, common.CustomEvent{Data: data})
return true
case <-stopChan:
return false
}
})
err = resp.Body.Close()
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusOK)
}
return nil
} else {
if consumeQuota {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return errorWrapper(err, "read_response_body_failed", http.StatusOK)
}
err = resp.Body.Close()
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusOK)
}
err = json.Unmarshal(responseBody, &textResponse)
if err != nil {
return errorWrapper(err, "unmarshal_response_body_failed", http.StatusOK)
}
if textResponse.Error.Type != "" {
return &OpenAIErrorWithStatusCode{
OpenAIError: textResponse.Error,
StatusCode: resp.StatusCode,
}
}
// Reset response body
resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
}
// We shouldn't set the header before we parse the response body, because the parse part may fail.
// And then we will have to send an error response, but in this case, the header has already been set.
// So the client will be confused by the response.
// For example, Postman will report error, and we cannot check the response at all.
for k, v := range resp.Header {
c.Writer.Header().Set(k, v[0])
}
c.Writer.WriteHeader(resp.StatusCode)
_, err = io.Copy(c.Writer, resp.Body)
if err != nil {
return errorWrapper(err, "copy_response_body_failed", http.StatusOK)
}
err = resp.Body.Close()
if err != nil {
return errorWrapper(err, "close_response_body_failed", http.StatusOK)
}
return nil
}
}
func RelayNotImplemented(c *gin.Context) {
err := OpenAIError{
Message: "API not implemented",
@@ -340,7 +135,19 @@ func RelayNotImplemented(c *gin.Context) {
Param: "",
Code: "api_not_implemented",
}
c.JSON(http.StatusOK, gin.H{
c.JSON(http.StatusNotImplemented, gin.H{
"error": err,
})
}
func RelayNotFound(c *gin.Context) {
err := OpenAIError{
Message: fmt.Sprintf("API not found: %s:%s", c.Request.Method, c.Request.URL.Path),
Type: "one_api_error",
Param: "",
Code: "api_not_found",
}
c.JSON(http.StatusNotFound, gin.H{
"error": err,
})
}

View File

@@ -119,7 +119,7 @@ func AddToken(c *gin.Context) {
cleanToken := model.Token{
UserId: c.GetInt("id"),
Name: token.Name,
Key: common.GetUUID(),
Key: common.GenerateKey(),
CreatedTime: common.GetTimestamp(),
AccessedTime: common.GetTimestamp(),
ExpiredTime: token.ExpiredTime,

View File

@@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
@@ -149,15 +150,18 @@ func Register(c *gin.Context) {
return
}
}
affCode := user.AffCode // this code is the inviter's code, not the user's own code
inviterId, _ := model.GetUserIdByAffCode(affCode)
cleanUser := model.User{
Username: user.Username,
Password: user.Password,
DisplayName: user.Username,
InviterId: inviterId,
}
if common.EmailVerificationEnabled {
cleanUser.Email = user.Email
}
if err := cleanUser.Insert(); err != nil {
if err := cleanUser.Insert(inviterId); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
@@ -228,7 +232,7 @@ func GetUser(c *gin.Context) {
return
}
myRole := c.GetInt("role")
if myRole <= user.Role {
if myRole <= user.Role && myRole != common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权获取同级或更高等级用户的信息",
@@ -255,7 +259,7 @@ func GenerateAccessToken(c *gin.Context) {
}
user.AccessToken = common.GetUUID()
if model.DB.Where("token = ?", user.AccessToken).First(user).RowsAffected != 0 {
if model.DB.Where("access_token = ?", user.AccessToken).First(user).RowsAffected != 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请重试,系统生成的 UUID 竟然重复了!",
@@ -279,6 +283,34 @@ func GenerateAccessToken(c *gin.Context) {
return
}
func GetAffCode(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if user.AffCode == "" {
user.AffCode = common.GetRandomString(4)
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user.AffCode,
})
return
}
func GetSelf(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
@@ -326,14 +358,14 @@ func UpdateUser(c *gin.Context) {
return
}
myRole := c.GetInt("role")
if myRole <= originUser.Role {
if myRole <= originUser.Role && myRole != common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权更新同权限等级或更高权限等级的用户信息",
})
return
}
if myRole <= updatedUser.Role {
if myRole <= updatedUser.Role && myRole != common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权将其他用户权限等级提升到大于等于自己的权限等级",
@@ -351,6 +383,9 @@ func UpdateUser(c *gin.Context) {
})
return
}
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota)))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -491,7 +526,7 @@ func CreateUser(c *gin.Context) {
Password: user.Password,
DisplayName: user.DisplayName,
}
if err := cleanUser.Insert(); err != nil {
if err := cleanUser.Insert(0); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
@@ -655,6 +690,9 @@ func EmailBind(c *gin.Context) {
})
return
}
if user.Role == common.RoleRootUser {
common.RootUserEmail = email
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@@ -85,7 +85,7 @@ func WeChatAuth(c *gin.Context) {
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
if err := user.Insert(); err != nil {
if err := user.Insert(0); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),

View File

@@ -2,21 +2,33 @@ version: '3.4'
services:
one-api:
image: ghcr.io/songquanpeng/one-api:latest
image: justsong/one-api:latest
container_name: one-api
restart: always
command: --log-dir /app/logs
ports:
- "3000:3000"
volumes:
- /home/ubuntu/data/one-api:/data
- /home/ubuntu/data/one-api/logs:/app/logs
# environment:
# REDIS_CONN_STRING: redis://default:redispw@localhost:49153
# SESSION_SECRET: random_string
# SQL_DSN: root:123456@tcp(localhost:3306)/one-api
- ./data:/data
- ./logs:/app/logs
environment:
- SQL_DSN=root:123456@tcp(host.docker.internal:3306)/one-api # 修改此行,或注释掉以使用 SQLite 作为数据库
- REDIS_CONN_STRING=redis://redis
- SESSION_SECRET=random_string # 修改为随机字符串
- TZ=Asia/Shanghai
# - NODE_TYPE=slave # 多机部署时从节点取消注释该行
# - SYNC_FREQUENCY=60 # 需要定期从数据库加载数据时取消注释该行
# - FRONTEND_BASE_URL=https://openai.justsong.cn # 多机部署时从节点取消注释该行
depends_on:
- redis
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk '{print $2}' | grep 'true'"]
test: [ "CMD-SHELL", "curl -s http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk '{print $2}' | grep 'true'" ]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:latest
container_name: redis
restart: always

19
go.mod
View File

@@ -8,12 +8,12 @@ require (
github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.0
github.com/go-playground/validator/v10 v10.12.0
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.14.0
github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.3.0
github.com/pkoukk/tiktoken-go v0.1.1
golang.org/x/crypto v0.8.0
golang.org/x/crypto v0.9.0
gorm.io/driver/mysql v1.4.3
gorm.io/driver/sqlite v1.4.3
gorm.io/gorm v1.24.0
@@ -21,11 +21,12 @@ require (
require (
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/bytedance/sonic v1.8.8 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.8.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -39,17 +40,17 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.3 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.7 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.9.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

41
go.sum
View File

@@ -1,8 +1,8 @@
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.8.8 h1:Kj4AYbZSeENfyXicsYppYKO0K2YWab+i2UTSY7Ukz9Q=
github.com/bytedance/sonic v1.8.8/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
@@ -17,6 +17,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0=
github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
@@ -29,8 +31,8 @@ github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Sw
github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
@@ -43,8 +45,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.12.0 h1:E4gtWgxWxp8YSxExrQFv5BpCahla0PVF2oTTEYaWQGI=
github.com/go-playground/validator/v10 v10.12.0/go.mod h1:hCAPuzYvKdP33pxWa+2+6AIKXEKqjIUyqsNCtbsSJrA=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -89,12 +91,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.3 h1:6BE2vPT0lqoz3fmOesHZiaiFh7889ssCo2GMvLCfiuA=
github.com/leodido/go-urn v1.2.3/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -108,8 +110,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us=
github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkoukk/tiktoken-go v0.1.1 h1:jtkYlIECjyM9OW1w4rjPmTohK4arORP9V25y6TM6nXo=
github.com/pkoukk/tiktoken-go v0.1.1/go.mod h1:boMWvk9pQCOTx11pgu0DrIdrAKgQzzJKUP6vLXaz7Rw=
@@ -128,8 +130,9 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
@@ -142,11 +145,11 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -154,8 +157,8 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

430
i18n/en.json Normal file
View File

@@ -0,0 +1,430 @@
{
"%.6f 额度": "%.6f quota",
"%d 点额度": "%d point quota",
"尚未实现": "Not yet implemented",
"余额不足": "Insufficient balance",
"\"通道「%s」#%d已被禁用\"": "\"Channel %s (#%d) has been disabled\"",
"通道「%s」#%d已被禁用原因%s": "Channel %s (#%d) has been disabled, reason: %s",
"测试已在运行中": "Test is already running",
"响应时间 %.2fs 超过阈值 %.2fs": "Response time %.2fs exceeds threshold %.2fs",
"通道测试完成": "Channel test completed",
"通道测试完成,如果没有收到禁用通知,说明所有通道都正常": "Channel test completed, if you have not received the disable notification, it means that all channels are normal",
"无法连接至 GitHub 服务器,请稍后重试!": "Unable to connect to GitHub server, please try again later!",
"返回值非法,用户字段为空,请稍后重试!": "The return value is illegal, the user field is empty, please try again later!",
"管理员未开启通过 GitHub 登录以及注册": "The administrator did not turn on login and registration via GitHub",
"管理员关闭了新用户注册": "The administrator has turned off new user registration",
"用户已被封禁": "User has been banned",
"该 GitHub 账户已被绑定": "The GitHub account has been bound",
"邮箱地址已被占用": "Email address is occupied",
"%s邮箱验证邮件": "%s Email verification email",
"<p>您好,你正在进行%s邮箱验证。</p>": "<p>Hello, you are verifying %s email.</p>",
"<p>您的验证码为: <strong>%s</strong></p>": "<p>Your verification code is: <strong>%s</strong></p>",
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>": "<p>The verification code is valid within %d minutes. If it is not your operation, please ignore it.</p>",
"无效的参数": "Invalid parameter",
"该邮箱地址未注册": "The email address is not registered",
"%s密码重置": "%s Password reset",
"<p>您好,你正在进行%s密码重置。</p>": "<p>Hello, you are resetting %s password.</p>",
"<p>点击<a href='%s'>此处</a>进行密码重置。</p>": "<p>Click <a href='%s'>here</a> to reset your password.</p>",
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>": "<p>The reset link is valid within %d minutes. If it is not your operation, please ignore it.</p>",
"重置链接非法或已过期": "Reset link is illegal or expired",
"无法启用 GitHub OAuth请先填入 GitHub Client ID 以及 GitHub Client Secret": "Unable to enable GitHub OAuth, please fill in GitHub Client ID and GitHub Client Secret first!",
"无法启用微信登录,请先填入微信登录相关配置信息!": "Unable to enable WeChat login, please fill in the relevant configuration information for WeChat login first!",
"无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!": "Unable to enable Turnstile verification, please fill in the relevant configuration information for Turnstile verification first!",
"兑换码名称长度必须在1-20之间": "The length of the redemption code name must be between 1-20",
"兑换码个数必须大于0": "The number of redemption codes must be greater than 0",
"一次兑换码批量生成的个数不能大于 100": "The number of redemption codes generated in a batch cannot be greater than 100",
"通过令牌「%s」使用模型 %s 消耗 %s模型倍率 %.2f,分组倍率 %.2f": "Using model %s with token %s consumes %s (model rate %.2f, group rate %.2f)",
"当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。": "The current group load is saturated, please try again later, or upgrade your account to improve service quality.",
"令牌名称长度必须在1-20之间": "The length of the token name must be between 1-20",
"令牌已过期,无法启用,请先修改令牌过期时间": "The token has expired and cannot be enabled. Please modify the token expiration time first",
"令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度": "The available quota of the token has been used up and cannot be enabled. Please modify the remaining quota of the token, or set it to unlimited quota",
"管理员关闭了密码登录": "The administrator has turned off password login",
"无法保存会话信息,请重试": "Unable to save session information, please try again",
"管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册": "The administrator has turned off registration via password. Please use the form of third-party account verification to register",
"输入不合法 ": "Input is illegal ",
"管理员开启了邮箱验证,请输入邮箱地址和验证码": "The administrator has turned on email verification, please enter the email address and verification code",
"验证码错误或已过期": "Verification code error or expired",
"无权获取同级或更高等级用户的信息": "No permission to get information of users at the same level or higher",
"请重试,系统生成的 UUID 竟然重复了!": "Please try again, the system-generated UUID is actually duplicated!",
"输入不合法": "Input is illegal",
"无权更新同权限等级或更高权限等级的用户信息": "No permission to update user information with the same permission level or higher permission level",
"管理员将用户额度从 %s修改为 %s": "The administrator changed the user quota from %s to %s",
"无权删除同权限等级或更高权限等级的用户": "No permission to delete users with the same permission level or higher permission level",
"无法创建权限大于等于自己的用户": "Unable to create users with permissions greater than or equal to your own",
"用户不存在": "User does not exist",
"无法禁用超级管理员用户": "Unable to disable super administrator user",
"无法删除超级管理员用户": "Unable to delete super administrator user",
"普通管理员用户无法提升其他用户为管理员": "Ordinary administrator users cannot promote other users to administrators",
"该用户已经是管理员": "The user is already an administrator",
"无法降级超级管理员用户": "Unable to downgrade super administrator user",
"该用户已经是普通用户": "The user is already an ordinary user",
"管理员未开启通过微信登录以及注册": "The administrator has not enabled login and registration via WeChat",
"该微信账号已被绑定": "The WeChat account has been bound",
"无权进行此操作,未登录且未提供 access token": "No permission to perform this operation, not logged in and no access token provided",
"无权进行此操作access token 无效": "No permission to perform this operation, access token is invalid",
"无权进行此操作,权限不足": "No permission to perform this operation, insufficient permissions",
"普通用户不支持指定渠道": "Ordinary users do not support specifying channels",
"无效的渠道 ID": "Invalid channel ID",
"该渠道已被禁用": "The channel has been disabled",
"无效的请求": "Invalid request",
"无可用渠道": "No available channels",
"Turnstile token 为空": "Turnstile token is empty",
"Turnstile 校验失败,请刷新重试!": "Turnstile verification failed, please refresh and try again!",
"id 为空!": "id is empty!",
"未提供兑换码": "No redemption code provided",
"无效的 user id": "Invalid user id",
"无效的兑换码": "Invalid redemption code",
"该兑换码已被使用": "The redemption code has been used",
"通过兑换码充值 %s": "Recharge %s through redemption code",
"未提供令牌": "No token provided",
"该令牌状态不可用": "The token status is not available",
"该令牌已过期": "The token has expired",
"该令牌额度已用尽": "The token quota has been used up",
"无效的令牌": "Invalid token",
"id 或 userId 为空!": "id or userId is empty!",
"quota 不能为负数!": "quota cannot be negative!",
"令牌额度不足": "Insufficient token quota",
"用户额度不足": "Insufficient user quota",
"您的额度即将用尽": "Your quota is about to run out",
"您的额度已用尽": "Your quota has been used up",
"%s当前剩余额度为 %d为了不影响您的使用请及时充值。<br/>充值链接:<a href='%s'>%s</a>": "%s, the current remaining quota is %d, in order not to affect your use, please recharge in time. <br/> Recharge link: <a href='%s'>%s</a>",
"affCode 为空!": "affCode is empty!",
"新用户注册赠送 %s": "New user registration gives %s",
"使用邀请码赠送 %s": "Use invitation code to give %s",
"邀请用户赠送 %s": "Invite users to give %s",
"用户名或密码为空": "Username or password is empty",
"用户名或密码错误,或用户已被封禁": "Username or password is wrong, or user has been banned",
"email 为空!": "email is empty!",
"GitHub id 为空!": "GitHub id is empty!",
"WeChat id 为空!": "WeChat id is empty!",
"username 为空!": "username is empty!",
"邮箱地址或密码为空!": "Email address or password is empty!",
"OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用": "OpenAI interface aggregation management, supports multiple channels including Azure, can be used for secondary distribution management key, only single executable file, Docker image has been packaged, one-click deployment, out of the box",
"未知类型": "Unknown type",
"不支持": "Not supported",
"操作成功完成!": "Operation completed successfully!",
"已启用": "Enabled",
"已禁用": "Disabled",
"未知状态": "Unknown status",
" 秒": "s",
"未测试": "Not tested",
"通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Channel ${name} test succeeded, time consumed ${time.toFixed(2)} s.",
"已成功开始测试所有已启用通道,请刷新页面查看结果。": "All enabled channels have been successfully tested, please refresh the page to view the results.",
"通道 ${name} 余额更新成功!": "Channel ${name} balance updated successfully!",
"已更新完毕所有已启用通道余额!": "The balance of all enabled channels has been updated!",
"搜索渠道的 ID名称和密钥 ...": "Search for channel ID, name and key ...",
"名称": "Name",
"分组": "Group",
"类型": "Type",
"状态": "Status",
"响应时间": "Response time",
"余额": "Balance",
"操作": "Operation",
"未更新": "Not updated",
"测试": "Test",
"更新余额": "Update balance",
"删除": "Delete",
"删除渠道 {channel.name}": "Delete channel {channel.name}",
"禁用": "Disable",
"启用": "Enable",
"编辑": "Edit",
"添加新的渠道": "Add a new channel",
"测试所有已启用通道": "Test all enabled channels",
"更新所有已启用通道余额": "Update the balance of all enabled channels",
"刷新": "Refresh",
"处理中...": "Processing...",
"绑定成功!": "Binding succeeded!",
"登录成功!": "Login succeeded!",
"操作失败,重定向至登录界面中...": "Operation failed, redirecting to the login page...",
"出现错误,第 ${count} 次重试中...": "An error occurred, retrying for the ${count} time...",
"首页": "Home",
"渠道": "Channel",
"令牌": "Token",
"兑换": "Redeem",
"充值": "Recharge",
"用户": "User",
"日志": "Log",
"设置": "Settings",
"关于": "About",
"聊天": "Chat",
"注销成功!": "Logout succeeded!",
"注销": "Logout",
"登录": "Login",
"注册": "Register",
"加载{name}中...": "Loading {name}...",
"未登录或登录已过期,请重新登录!": "Not logged in or login has expired, please log in again!",
"用户登录": "User login",
"\"用户名\"": "\"Username\"",
"\"密码\"": "\"Password\"",
"忘记密码?": "Forget password?",
"点击重置": "Click to reset",
" 没有账户?": "; No account?",
"点击注册": "Click to register",
"微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)": "Scan the QR code of WeChat to follow the official account, enter \"verification code\" to get the verification code (valid within three minutes)",
"\"验证码\"": "\"Verification code\"",
"全部用户": "All users",
"当前用户": "Current user",
"'全部'": "'All'",
"'充值'": "'Recharge'",
"'消费'": "'Consumption'",
"'管理'": "'Management'",
"'系统'": "'System'",
" 充值 ": " Recharge ",
" 消费 ": " Consumption ",
" 管理 ": " Management ",
" 系统 ": " System ",
" 未知 ": " Unknown ",
"时间": "Time",
"详情": "Details",
"选择模式": "Select mode",
"选择明细分类": "Select details category",
"模型倍率不是合法的 JSON 字符串": "Model rate is not a valid JSON string",
"分组倍率不是合法的 JSON 字符串": "Group rate is not a valid JSON string",
"通用设置": "General Settings",
"充值链接": "Recharge Link",
"例如发卡网站的购买链接": "For example, the purchase link of the card issuing website",
"聊天页面链接": "Chat Page Link",
"例如 ChatGPT Next Web 的部署地址": "For example, the deployment address of ChatGPT Next Web",
"单位美元额度": "Unit Dollar Quota",
"一单位货币能兑换的额度": "Quota that can be exchanged for one unit of currency",
"启用额度消费日志记录": "Enable quota consumption log recording",
"以货币形式显示额度": "Display quota in the form of currency",
"相关 API 显示令牌额度而非用户额度": "Related API displays token quota instead of user quota",
"保存通用设置": "Save General Settings",
"监控设置": "Monitoring Settings",
"最长响应时间": "Longest Response Time",
"单位秒": "Unit in seconds",
"当运行通道全部测试时": "When all operating channels are tested",
"超过此时间将自动禁用通道": "Channels will be automatically disabled if this time is exceeded",
"额度提醒阈值": "Quota reminder threshold",
"低于此额度时将发送邮件提醒用户": "Email will be sent to remind users when the quota is below this",
"失败时自动禁用通道": "Automatically disable the channel when it fails",
"保存监控设置": "Save Monitoring Settings",
"额度设置": "Quota Settings",
"新用户初始额度": "Initial quota for new users",
"例如": "For example",
"请求预扣费额度": "Request for pre-deducted quota",
"请求结束后多退少补": "Refund more or less after the request ends",
"邀请新用户奖励额度": "Invite new users to reward quota",
"新用户使用邀请码奖励额度": "New user rewards quota using invitation code",
"保存额度设置": "Save Quota Settings",
"倍率设置": "Rate Settings",
"模型倍率": "Model rate",
"为一个 JSON 文本": "Is a JSON text",
"键为模型名称": "Key is model name",
"值为倍率": "Value is the rate",
"分组倍率": "Group rate",
"键为分组名称": "Key is group name",
"保存倍率设置": "Save Rate Settings",
"已是最新版本": "Is the latest version",
"检查更新": "Check for updates",
"公告": "Announcement",
"在此输入新的公告内容": "Enter new announcement content here",
"保存公告": "Save Announcement",
"个性化设置": "Personalization Settings",
"系统名称": "System Name",
"在此输入系统名称": "Enter the system name here",
"设置系统名称": "Set system name",
"图片地址": "Image URL",
"在此输入 Logo 图片地址": "Enter the Logo image URL here",
"首页内容": "Home Page Content",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Enter the homepage content here, supports Markdown & HTML code. Once set, the status information of the homepage will not be displayed. If a link is entered, it will be used as the src attribute of the iframe, allowing you to set any webpage as the homepage.",
"保存首页内容": "Save Home Page Content",
"在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面": "Enter new about content here, supports Markdown & HTML code. If a link is entered, it will be used as the src attribute of the iframe, allowing you to set any webpage as the about page.",
"保存关于": "Save About",
"移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目": "Removal of One API copyright mark must first be authorized. Project maintenance requires a lot of effort. If this project is meaningful to you, please actively support it.",
"页脚": "Footer",
"在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码": "Enter the new footer here, leave blank to use the default footer, supports HTML code.",
"设置页脚": "Set Footer",
"新版本": "New Version",
"关闭": "Close",
"密码已重置并已复制到剪贴板": "Password has been reset and copied to clipboard",
"密码重置确认": "Password Reset Confirmation",
"邮箱地址": "Email Address",
"提交": "Submit",
"请稍后几秒重试": "Please retry in a few seconds",
"正在检查用户环境": "Checking user environment",
"重置邮件发送成功": "Reset mail sent successfully",
"请检查邮箱": "Please check your email",
"密码重置": "Password Reset",
"令牌已重置并已复制到剪贴板": "Token has been reset and copied to clipboard",
"邀请链接已复制到剪切板": "Invitation link has been copied to clipboard",
"微信账户绑定成功": "WeChat account binding succeeded",
"验证码发送成功": "Verification code sent successfully",
"邮箱账户绑定成功": "Email account binding succeeded",
"注意": "Note",
"此处生成的令牌用于系统管理": "The token generated here is used for system management",
"而非用于请求 OpenAI 相关的服务": "Not for requesting OpenAI related services",
"请知悉": "Please be aware",
"更新个人信息": "Update Personal Information",
"生成系统访问令牌": "Generate System Access Token",
"复制邀请链接": "Copy Invitation Link",
"账号绑定": "Account Binding",
"绑定微信账号": "Bind WeChat Account",
"微信扫码关注公众号": "Scan the QR code with WeChat to follow the official account",
"输入": "Enter",
"验证码": "Verification Code",
"获取验证码": "Get Verification Code",
"三分钟内有效": "Valid for three minutes",
"绑定": "Bind",
"绑定 GitHub 账号": "Bind GitHub Account",
"绑定邮箱地址": "Bind Email Address",
"输入邮箱地址": "Enter Email Address",
"未使用": "Unused",
"已使用": "Used",
"操作成功完成": "Operation successfully completed",
"搜索兑换码的 ID 和名称": "Search for ID and name",
"额度": "Quota",
"创建时间": "Creation Time",
"兑换时间": "Redemption Time",
"尚未兑换": "Not yet redeemed",
"已复制到剪贴板": "Copied to clipboard",
"无法复制到剪贴板": "Unable to copy to clipboard",
"请手动复制": "Please copy manually",
"已将兑换码填入搜索框": "The voucher code has been filled into the search box",
"复制": "Copy",
"添加新的兑换码": "Add a new voucher",
"密码长度不得小于 8 位": "Password length must not be less than 8 characters",
"两次输入的密码不一致": "The two passwords entered do not match",
"注册成功": "Registration succeeded",
"请稍后几秒重试Turnstile 正在检查用户环境": "Please retry in a few seconds, Turnstile is checking user environment",
"验证码发送成功,请检查你的邮箱": "Verification code sent successfully, please check your email",
"新用户注册": "New User Registration",
"输入用户名,最长 12 位": "Enter username, up to 12 characters",
"输入密码,最短 8 位,最长 20 位": "Enter password, at least 8 characters and up to 20 characters",
"输入验证码": "Enter Verification Code",
"已有账户": "Already have an account",
"点击登录": "Click to log in",
"服务器地址": "Server Address",
"更新服务器地址": "Update Server Address",
"配置登录注册": "Configure Login/Registration",
"允许通过密码进行登录": "Allow login via password",
"允许通过密码进行注册": "Allow registration via password",
"通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password",
"允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account",
"允许通过微信登录 & 注册": "Allow login & registration via WeChat",
"允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way",
"启用 Turnstile 用户校验": "Enable Turnstile user verification",
"配置 SMTP": "Configure SMTP",
"用以支持系统的邮件发送": "To support the system email sending",
"SMTP 服务器地址": "SMTP Server Address",
"例如smtp.qq.com": "For example: smtp.qq.com",
"SMTP 端口": "SMTP Port",
"默认: 587": "Default: 587",
"SMTP 账户": "SMTP Account",
"通常是邮箱地址": "Usually an email address",
"发送者邮箱": "Sender email",
"通常和邮箱地址保持一致": "Usually consistent with the email address",
"SMTP 访问凭证": "SMTP Access Credential",
"敏感信息不会发送到前端显示": "Sensitive information will not be displayed in the frontend",
"保存 SMTP 设置": "Save SMTP Settings",
"配置 GitHub OAuth App": "Configure GitHub OAuth App",
"用以支持通过 GitHub 进行登录注册": "To support login & registration via GitHub",
"点击此处": "Click here",
"管理你的 GitHub OAuth App": "Manage your GitHub OAuth App",
"输入你注册的 GitHub OAuth APP 的 ID": "Enter your registered GitHub OAuth APP ID",
"保存 GitHub OAuth 设置": "Save GitHub OAuth Settings",
"配置 WeChat Server": "Configure WeChat Server",
"用以支持通过微信进行登录注册": "To support login & registration via WeChat",
"了解 WeChat Server": "Learn about WeChat Server",
"WeChat Server 访问凭证": "WeChat Server Access Credential",
"微信公众号二维码图片链接": "WeChat Public Account QR Code Image Link",
"输入一个图片链接": "Enter an image link",
"保存 WeChat Server 设置": "Save WeChat Server Settings",
"配置 Turnstile": "Configure Turnstile",
"用以支持用户校验": "To support user verification",
"管理你的 Turnstile Sites推荐选择 Invisible Widget Type": "Manage your Turnstile Sites, recommend selecting Invisible Widget Type",
"输入你注册的 Turnstile Site Key": "Enter your registered Turnstile Site Key",
"保存 Turnstile 设置": "Save Turnstile Settings",
"已过期": "Expired",
"已耗尽": "Exhausted",
"搜索令牌的名称 ...": "Search for the name of the token...",
"已用额度": "Quota used",
"剩余额度": "Remaining quota",
"过期时间": "Expiration time",
"无": "None",
"无限制": "Unlimited",
"永不过期": "Never expires",
"无法复制到剪贴板,请手动复制,已将令牌填入搜索框": "Unable to copy to clipboard, please copy manually, the token has been entered into the search box",
"删除令牌": "Delete Token",
"添加新的令牌": "Add New Token",
"普通用户": "Regular User",
"管理员": "Admin",
"超级管理员": "Super Admin",
"未知身份": "Unknown Identity",
"已激活": "Activated",
"已封禁": "Banned",
"搜索用户的 ID用户名显示名称以及邮箱地址 ...": "Search user ID, username, display name, and email address...",
"用户名": "Username",
"统计信息": "Statistics",
"用户角色": "User Role",
"未绑定邮箱地址": "Email not bound",
"请求次数": "Number of Requests",
"提升": "Promote",
"降级": "Demote",
"删除用户": "Delete User",
"添加新的用户": "Add New User",
"自定义": "Custom",
"等价金额": "Equivalent Amount",
"错误": "Error",
"错误:未登录或登录已过期,请重新登录": "Error: Not logged in or login has expired, please log in again",
"错误:请求次数过多,请稍后再试": "Error: Too many requests, please try again later",
"错误:服务器内部错误,请联系管理员": "Error: Server internal error, please contact the administrator",
"本站仅作演示之用,无服务端": "This site is for demonstration purposes only, no server-side",
"错误:": "Error:",
"新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面": "New version available: ${data.version}, please refresh the page using shortcut Shift + F5",
"无法正常连接至服务器": "Unable to connect to the server normally",
"管理渠道": "Manage Channels",
"系统状况": "System Status",
"系统信息": "System Information",
"系统信息总览": "System Information Overview",
"版本": "Version",
"源码": "Source Code",
"启动时间": "Startup Time",
"系统配置": "System Configuration",
"系统配置总览": "System Configuration Overview",
"邮箱验证": "Email Verification",
"未启用": "Not Enabled",
"GitHub 身份验证": "GitHub Authentication",
"微信身份验证": "WeChat Authentication",
"Turnstile 用户校验": "Turnstile User Verification",
"创建新的渠道": "Create New Channel",
"镜像": "Mirror",
"请输入镜像站地址格式为https://domain.com可不填不填则使用渠道默认值": "Please enter the mirror site address, the format is: https://domain.com, it can be left blank, if left blank, the default value of the channel will be used",
"模型": "Model",
"请选择该通道所支持的模型": "Please select the model supported by the channel",
"填入基础模型": "Fill in the basic model",
"填入所有模型": "Fill in all models",
"清除所有模型": "Clear all models",
"密钥": "Key",
"请输入密钥": "Please enter the key",
"批量创建": "Batch Create",
"更新渠道信息": "Update Channel Information",
"我的令牌": "My Tokens",
"管理兑换码": "Manage Redeem Codes",
"兑换码": "Redeem Code",
"管理用户": "Manage Users",
"额度明细": "Quota Details",
"个人设置": "Personal Settings",
"运营设置": "Operation Settings",
"系统设置": "System Settings",
"其他设置": "Other Settings",
"项目仓库地址": "Project Repository Address",
"可在设置页面设置关于内容,支持 HTML & Markdown": "You can set the content about in the settings page, support HTML & Markdown",
"由{' '}": "built by{' '}",
"构建,源代码遵循{' '}": ", the source code licensed under{' '}",
"MIT 协议": "MIT License",
"充值额度": "Recharge Quota",
"获取兑换码": "Get Redeem Code",
"一个月后过期": "Expires after one month",
"一天后过期": "Expires after one day",
"一小时后过期": "Expires after one hour",
"一分钟后过期": "Expires after one minute",
"创建新的令牌": "Create New Token",
"注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。": "Note that the quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account.",
"设置为无限额度": "Set to unlimited quota",
"更新令牌信息": "Update Token Information",
"请输入充值码!": "Please enter the recharge code!",
"请输入名称": "Please enter a name",
"请输入密钥,一行一个": "Please enter the key, one per line"
}

61
i18n/translate.py Normal file
View File

@@ -0,0 +1,61 @@
import argparse
import json
import os
def list_file_paths(path):
file_paths = []
for root, dirs, files in os.walk(path):
if "node_modules" in dirs:
dirs.remove("node_modules")
if "build" in dirs:
dirs.remove("build")
if "i18n" in dirs:
dirs.remove("i18n")
for file in files:
file_path = os.path.join(root, file)
if file_path.endswith("png") or file_path.endswith("ico") or file_path.endswith("db") or file_path.endswith("exe"):
continue
file_paths.append(file_path)
for dir in dirs:
dir_path = os.path.join(root, dir)
file_paths += list_file_paths(dir_path)
return file_paths
def replace_keys_in_repository(repo_path, json_file_path):
with open(json_file_path, 'r', encoding="utf-8") as json_file:
key_value_pairs = json.load(json_file)
pairs = []
for key, value in key_value_pairs.items():
pairs.append((key, value))
pairs.sort(key=lambda x: len(x[0]), reverse=True)
files = list_file_paths(repo_path)
print('Total files: {}'.format(len(files)))
for file_path in files:
replace_keys_in_file(file_path, pairs)
def replace_keys_in_file(file_path, pairs):
try:
with open(file_path, 'r', encoding="utf-8") as file:
content = file.read()
for key, value in pairs:
content = content.replace(key, value)
with open(file_path, 'w', encoding="utf-8") as file:
file.write(content)
except UnicodeDecodeError:
print('UnicodeDecodeError: {}'.format(file_path))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Replace keys in repository.')
parser.add_argument('--repository_path', help='Path to repository')
parser.add_argument('--json_file_path', help='Path to JSON file')
args = parser.parse_args()
replace_keys_in_repository(args.repository_path, args.json_file_path)

32
main.go
View File

@@ -6,8 +6,8 @@ import (
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
"log"
"one-api/common"
"one-api/controller"
"one-api/middleware"
"one-api/model"
"one-api/router"
@@ -30,29 +30,49 @@ func main() {
// Initialize SQL Database
err := model.InitDB()
if err != nil {
common.FatalLog(err)
common.FatalLog("failed to initialize database: " + err.Error())
}
defer func() {
err := model.CloseDB()
if err != nil {
common.FatalLog(err)
common.FatalLog("failed to close database: " + err.Error())
}
}()
// Initialize Redis
err = common.InitRedisClient()
if err != nil {
common.FatalLog(err)
common.FatalLog("failed to initialize Redis: " + err.Error())
}
// Initialize options
model.InitOptionMap()
if common.RedisEnabled {
model.InitChannelCache()
}
if os.Getenv("SYNC_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("SYNC_FREQUENCY"))
if err != nil {
common.FatalLog(err)
common.FatalLog("failed to parse SYNC_FREQUENCY: " + err.Error())
}
go model.SyncOptions(frequency)
if common.RedisEnabled {
go model.SyncChannelCache(frequency)
}
}
if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY"))
if err != nil {
common.FatalLog("failed to parse CHANNEL_UPDATE_FREQUENCY: " + err.Error())
}
go controller.AutomaticallyUpdateChannels(frequency)
}
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
if err != nil {
common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error())
}
go controller.AutomaticallyTestChannels(frequency)
}
// Initialize HTTP server
@@ -78,6 +98,6 @@ func main() {
}
err = server.Run(":" + port)
if err != nil {
log.Println(err)
common.FatalLog("failed to start HTTP server: " + err.Error())
}
}

View File

@@ -91,7 +91,7 @@ func TokenAuth() func(c *gin.Context) {
key = parts[0]
token, err := model.ValidateUserToken(key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
c.JSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"message": err.Error(),
"type": "one_api_error",
@@ -100,8 +100,8 @@ func TokenAuth() func(c *gin.Context) {
c.Abort()
return
}
if !model.IsUserEnabled(token.UserId) {
c.JSON(http.StatusOK, gin.H{
if !model.CacheIsUserEnabled(token.UserId) {
c.JSON(http.StatusForbidden, gin.H{
"error": gin.H{
"message": "用户已被封禁",
"type": "one_api_error",
@@ -112,6 +112,7 @@ func TokenAuth() func(c *gin.Context) {
}
c.Set("id", token.UserId)
c.Set("token_id", token.Id)
c.Set("token_name", token.Name)
requestURL := c.Request.URL.String()
consumeQuota := true
if strings.HasPrefix(requestURL, "/v1/models") {
@@ -122,7 +123,7 @@ func TokenAuth() func(c *gin.Context) {
if model.IsAdmin(token.UserId) {
c.Set("channelId", parts[1])
} else {
c.JSON(http.StatusOK, gin.H{
c.JSON(http.StatusForbidden, gin.H{
"error": gin.H{
"message": "普通用户不支持指定渠道",
"type": "one_api_error",

View File

@@ -10,6 +10,6 @@ func CORS() gin.HandlerFunc {
config.AllowAllOrigins = true
config.AllowCredentials = true
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "Accept", "Connection"}
config.AllowHeaders = []string{"*"}
return cors.New(config)
}

View File

@@ -7,16 +7,24 @@ import (
"one-api/common"
"one-api/model"
"strconv"
"strings"
)
type ModelRequest struct {
Model string `json:"model"`
}
func Distribute() func(c *gin.Context) {
return func(c *gin.Context) {
userId := c.GetInt("id")
userGroup, _ := model.CacheGetUserGroup(userId)
c.Set("group", userGroup)
var channel *model.Channel
channelId, ok := c.Get("channelId")
if ok {
id, err := strconv.Atoi(channelId.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"message": "无效的渠道 ID",
"type": "one_api_error",
@@ -27,7 +35,7 @@ func Distribute() func(c *gin.Context) {
}
channel, err = model.GetChannelById(id, true)
if err != nil {
c.JSON(200, gin.H{
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"message": "无效的渠道 ID",
"type": "one_api_error",
@@ -37,7 +45,7 @@ func Distribute() func(c *gin.Context) {
return
}
if channel.Status != common.ChannelStatusEnabled {
c.JSON(200, gin.H{
c.JSON(http.StatusForbidden, gin.H{
"error": gin.H{
"message": "该渠道已被禁用",
"type": "one_api_error",
@@ -48,10 +56,26 @@ func Distribute() func(c *gin.Context) {
}
} else {
// Select a channel for the user
var err error
channel, err = model.GetRandomChannel()
var modelRequest ModelRequest
err := common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
c.JSON(200, gin.H{
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"message": "无效的请求",
"type": "one_api_error",
},
})
c.Abort()
return
}
if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") {
if modelRequest.Model == "" {
modelRequest.Model = "text-moderation-stable"
}
}
channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": gin.H{
"message": "无可用渠道",
"type": "one_api_error",
@@ -65,11 +89,9 @@ func Distribute() func(c *gin.Context) {
c.Set("channel_id", channel.Id)
c.Set("channel_name", channel.Name)
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
if channel.Type == common.ChannelTypeCustom || channel.Type == common.ChannelTypeAzure {
c.Set("base_url", channel.BaseURL)
if channel.Type == common.ChannelTypeAzure {
c.Set("api_version", channel.Other)
}
c.Set("base_url", channel.BaseURL)
if channel.Type == common.ChannelTypeAzure {
c.Set("api_version", channel.Other)
}
c.Next()
}

72
model/ability.go Normal file
View File

@@ -0,0 +1,72 @@
package model
import (
"one-api/common"
"strings"
)
type Ability struct {
Group string `json:"group" gorm:"type:varchar(32);primaryKey;autoIncrement:false"`
Model string `json:"model" gorm:"primaryKey;autoIncrement:false"`
ChannelId int `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
Enabled bool `json:"enabled"`
}
func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) {
ability := Ability{}
var err error = nil
if common.UsingSQLite {
err = DB.Where("`group` = ? and model = ? and enabled = 1", group, model).Order("RANDOM()").Limit(1).First(&ability).Error
} else {
err = DB.Where("`group` = ? and model = ? and enabled = 1", group, model).Order("RAND()").Limit(1).First(&ability).Error
}
if err != nil {
return nil, err
}
channel := Channel{}
err = DB.First(&channel, "id = ?", ability.ChannelId).Error
return &channel, err
}
func (channel *Channel) AddAbilities() error {
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
abilities := make([]Ability, 0, len(models_))
for _, model := range models_ {
for _, group := range groups_ {
ability := Ability{
Group: group,
Model: model,
ChannelId: channel.Id,
Enabled: channel.Status == common.ChannelStatusEnabled,
}
abilities = append(abilities, ability)
}
}
return DB.Create(&abilities).Error
}
func (channel *Channel) DeleteAbilities() error {
return DB.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error
}
// UpdateAbilities updates abilities of this channel.
// Make sure the channel is completed before calling this function.
func (channel *Channel) UpdateAbilities() error {
// A quick and dirty way to update abilities
// First delete all abilities of this channel
err := channel.DeleteAbilities()
if err != nil {
return err
}
// Then add new abilities
err = channel.AddAbilities()
if err != nil {
return err
}
return nil
}
func UpdateAbilityStatus(channelId int, status bool) error {
return DB.Model(&Ability{}).Where("channel_id = ?", channelId).Select("enabled").Update("enabled", status).Error
}

163
model/cache.go Normal file
View File

@@ -0,0 +1,163 @@
package model
import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"one-api/common"
"strconv"
"strings"
"sync"
"time"
)
const (
TokenCacheSeconds = 60 * 60
UserId2GroupCacheSeconds = 60 * 60
UserId2QuotaCacheSeconds = 10 * 60
UserId2StatusCacheSeconds = 60 * 60
)
func CacheGetTokenByKey(key string) (*Token, error) {
var token Token
if !common.RedisEnabled {
err := DB.Where("`key` = ?", key).First(&token).Error
return &token, err
}
tokenObjectString, err := common.RedisGet(fmt.Sprintf("token:%s", key))
if err != nil {
err := DB.Where("`key` = ?", key).First(&token).Error
if err != nil {
return nil, err
}
jsonBytes, err := json.Marshal(token)
if err != nil {
return nil, err
}
err = common.RedisSet(fmt.Sprintf("token:%s", key), string(jsonBytes), TokenCacheSeconds*time.Second)
if err != nil {
common.SysError("Redis set token error: " + err.Error())
}
return &token, nil
}
err = json.Unmarshal([]byte(tokenObjectString), &token)
return &token, err
}
func CacheGetUserGroup(id int) (group string, err error) {
if !common.RedisEnabled {
return GetUserGroup(id)
}
group, err = common.RedisGet(fmt.Sprintf("user_group:%d", id))
if err != nil {
group, err = GetUserGroup(id)
if err != nil {
return "", err
}
err = common.RedisSet(fmt.Sprintf("user_group:%d", id), group, UserId2GroupCacheSeconds*time.Second)
if err != nil {
common.SysError("Redis set user group error: " + err.Error())
}
}
return group, err
}
func CacheGetUserQuota(id int) (quota int, err error) {
if !common.RedisEnabled {
return GetUserQuota(id)
}
quotaString, err := common.RedisGet(fmt.Sprintf("user_quota:%d", id))
if err != nil {
quota, err = GetUserQuota(id)
if err != nil {
return 0, err
}
err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), UserId2QuotaCacheSeconds*time.Second)
if err != nil {
common.SysError("Redis set user quota error: " + err.Error())
}
return quota, err
}
quota, err = strconv.Atoi(quotaString)
return quota, err
}
func CacheIsUserEnabled(userId int) bool {
if !common.RedisEnabled {
return IsUserEnabled(userId)
}
enabled, err := common.RedisGet(fmt.Sprintf("user_enabled:%d", userId))
if err != nil {
status := common.UserStatusDisabled
if IsUserEnabled(userId) {
status = common.UserStatusEnabled
}
enabled = fmt.Sprintf("%d", status)
err = common.RedisSet(fmt.Sprintf("user_enabled:%d", userId), enabled, UserId2StatusCacheSeconds*time.Second)
if err != nil {
common.SysError("Redis set user enabled error: " + err.Error())
}
}
return enabled == "1"
}
var group2model2channels map[string]map[string][]*Channel
var channelSyncLock sync.RWMutex
func InitChannelCache() {
newChannelId2channel := make(map[int]*Channel)
var channels []*Channel
DB.Find(&channels)
for _, channel := range channels {
newChannelId2channel[channel.Id] = channel
}
var abilities []*Ability
DB.Find(&abilities)
groups := make(map[string]bool)
for _, ability := range abilities {
groups[ability.Group] = true
}
newGroup2model2channels := make(map[string]map[string][]*Channel)
for group := range groups {
newGroup2model2channels[group] = make(map[string][]*Channel)
}
for _, channel := range channels {
groups := strings.Split(channel.Group, ",")
for _, group := range groups {
models := strings.Split(channel.Models, ",")
for _, model := range models {
if _, ok := newGroup2model2channels[group][model]; !ok {
newGroup2model2channels[group][model] = make([]*Channel, 0)
}
newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel)
}
}
}
channelSyncLock.Lock()
group2model2channels = newGroup2model2channels
channelSyncLock.Unlock()
common.SysLog("channels synced from database")
}
func SyncChannelCache(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Second)
common.SysLog("syncing channels from database")
InitChannelCache()
}
}
func CacheGetRandomSatisfiedChannel(group string, model string) (*Channel, error) {
if !common.RedisEnabled {
return GetRandomSatisfiedChannel(group, model)
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
channels := group2model2channels[group][model]
if len(channels) == 0 {
return nil, errors.New("channel not found")
}
idx := rand.Intn(len(channels))
return channels[idx], nil
}

View File

@@ -1,14 +1,14 @@
package model
import (
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
"one-api/common"
)
type Channel struct {
Id int `json:"id"`
Type int `json:"type" gorm:"default:0"`
Key string `json:"key" gorm:"not null"`
Key string `json:"key" gorm:"not null;index"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"`
Weight int `json:"weight"`
@@ -19,6 +19,9 @@ type Channel struct {
Other string `json:"other"`
Balance float64 `json:"balance"` // in USD
BalanceUpdatedTime int64 `json:"balance_updated_time" gorm:"bigint"`
Models string `json:"models"`
Group string `json:"group" gorm:"type:varchar(32);default:'default'"`
UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"`
}
func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) {
@@ -33,7 +36,7 @@ func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) {
}
func SearchChannels(keyword string) (channels []*Channel, err error) {
err = DB.Omit("key").Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&channels).Error
err = DB.Omit("key").Where("id = ? or name LIKE ? or key = ?", keyword, keyword+"%", keyword).Find(&channels).Error
return channels, err
}
@@ -49,13 +52,12 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) {
}
func GetRandomChannel() (*Channel, error) {
// TODO: consider weight
channel := Channel{}
var err error = nil
if common.UsingSQLite {
err = DB.Where("status = ?", common.ChannelStatusEnabled).Order("RANDOM()").Limit(1).First(&channel).Error
err = DB.Where("status = ? and `group` = ?", common.ChannelStatusEnabled, "default").Order("RANDOM()").Limit(1).First(&channel).Error
} else {
err = DB.Where("status = ?", common.ChannelStatusEnabled).Order("RAND()").Limit(1).First(&channel).Error
err = DB.Where("status = ? and `group` = ?", common.ChannelStatusEnabled, "default").Order("RAND()").Limit(1).First(&channel).Error
}
return &channel, err
}
@@ -63,18 +65,36 @@ func GetRandomChannel() (*Channel, error) {
func BatchInsertChannels(channels []Channel) error {
var err error
err = DB.Create(&channels).Error
return err
if err != nil {
return err
}
for _, channel_ := range channels {
err = channel_.AddAbilities()
if err != nil {
return err
}
}
return nil
}
func (channel *Channel) Insert() error {
var err error
err = DB.Create(channel).Error
if err != nil {
return err
}
err = channel.AddAbilities()
return err
}
func (channel *Channel) Update() error {
var err error
err = DB.Model(channel).Updates(channel).Error
if err != nil {
return err
}
DB.Model(channel).First(channel, "id = ?", channel.Id)
err = channel.UpdateAbilities()
return err
}
@@ -101,12 +121,27 @@ func (channel *Channel) UpdateBalance(balance float64) {
func (channel *Channel) Delete() error {
var err error
err = DB.Delete(channel).Error
if err != nil {
return err
}
err = channel.DeleteAbilities()
return err
}
func UpdateChannelStatusById(id int, status int) {
err := DB.Model(&Channel{}).Where("id = ?", id).Update("status", status).Error
err := UpdateAbilityStatus(id, status == common.ChannelStatusEnabled)
if err != nil {
common.SysError("failed to update ability status: " + err.Error())
}
err = DB.Model(&Channel{}).Where("id = ?", id).Update("status", status).Error
if err != nil {
common.SysError("failed to update channel status: " + err.Error())
}
}
func UpdateChannelUsedQuota(id int, quota int) {
err := DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error
if err != nil {
common.SysError("failed to update channel used quota: " + err.Error())
}
}

70
model/log.go Normal file
View File

@@ -0,0 +1,70 @@
package model
import (
"gorm.io/gorm"
"one-api/common"
)
type Log struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
CreatedAt int64 `json:"created_at" gorm:"bigint"`
Type int `json:"type" gorm:"index"`
Content string `json:"content"`
}
const (
LogTypeUnknown = iota
LogTypeTopup
LogTypeConsume
LogTypeManage
LogTypeSystem
)
func RecordLog(userId int, logType int, content string) {
if logType == LogTypeConsume && !common.LogConsumeEnabled {
return
}
log := &Log{
UserId: userId,
CreatedAt: common.GetTimestamp(),
Type: logType,
Content: content,
}
err := DB.Create(log).Error
if err != nil {
common.SysError("failed to record log: " + err.Error())
}
}
func GetAllLogs(logType int, startIdx int, num int) (logs []*Log, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = DB
} else {
tx = DB.Where("type = ?", logType)
}
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error
return logs, err
}
func GetUserLogs(userId int, logType int, startIdx int, num int) (logs []*Log, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = DB.Where("user_id = ?", userId)
} else {
tx = DB.Where("user_id = ? and type = ?", userId, logType)
}
err = tx.Order("id desc").Limit(num).Offset(startIdx).Omit("id").Find(&logs).Error
return logs, err
}
func SearchAllLogs(keyword string) (logs []*Log, err error) {
err = DB.Where("type = ? or content LIKE ?", keyword, keyword+"%").Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error
return logs, err
}
func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) {
err = DB.Where("user_id = ? and type = ?", userId, keyword).Order("id desc").Limit(common.MaxRecentItems).Omit("id").Find(&logs).Error
return logs, err
}

View File

@@ -42,19 +42,24 @@ func InitDB() (err error) {
var db *gorm.DB
if os.Getenv("SQL_DSN") != "" {
// Use MySQL
common.SysLog("using MySQL as database")
db, err = gorm.Open(mysql.Open(os.Getenv("SQL_DSN")), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
} else {
// Use SQLite
common.SysLog("SQL_DSN not set, using SQLite as database")
common.UsingSQLite = true
db, err = gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
common.SysLog("SQL_DSN not set, using SQLite as database")
}
common.SysLog("database connected")
if err == nil {
DB = db
if !common.IsMasterNode {
return nil
}
err := db.AutoMigrate(&Channel{})
if err != nil {
return err
@@ -75,6 +80,15 @@ func InitDB() (err error) {
if err != nil {
return err
}
err = db.AutoMigrate(&Ability{})
if err != nil {
return err
}
err = db.AutoMigrate(&Log{})
if err != nil {
return err
}
common.SysLog("database migrated")
err = createRootAccountIfNeed()
return err
} else {

View File

@@ -34,6 +34,9 @@ func InitOptionMap() {
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled)
common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled)
common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled)
common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled)
common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64)
common.OptionMap["SMTPServer"] = ""
common.OptionMap["SMTPFrom"] = ""
@@ -55,10 +58,15 @@ func InitOptionMap() {
common.OptionMap["TurnstileSiteKey"] = ""
common.OptionMap["TurnstileSecretKey"] = ""
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
common.OptionMap["QuotaForInviter"] = strconv.Itoa(common.QuotaForInviter)
common.OptionMap["QuotaForInvitee"] = strconv.Itoa(common.QuotaForInvitee)
common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold)
common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota)
common.OptionMap["ModelRatio"] = common.ModelRatio2JSONString()
common.OptionMap["GroupRatio"] = common.GroupRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
common.OptionMap["ChatLink"] = common.ChatLink
common.OptionMap["QuotaPerUnit"] = strconv.FormatFloat(common.QuotaPerUnit, 'f', -1, 64)
common.OptionMapRWMutex.Unlock()
loadOptionsFromDatabase()
}
@@ -68,7 +76,7 @@ func loadOptionsFromDatabase() {
for _, option := range options {
err := updateOptionMap(option.Key, option.Value)
if err != nil {
common.SysError("Failed to update option map: " + err.Error())
common.SysError("failed to update option map: " + err.Error())
}
}
}
@@ -76,7 +84,7 @@ func loadOptionsFromDatabase() {
func SyncOptions(frequency int) {
for {
time.Sleep(time.Duration(frequency) * time.Second)
common.SysLog("Syncing options from database")
common.SysLog("syncing options from database")
loadOptionsFromDatabase()
}
}
@@ -133,6 +141,12 @@ func updateOptionMap(key string, value string) (err error) {
common.RegisterEnabled = boolValue
case "AutomaticDisableChannelEnabled":
common.AutomaticDisableChannelEnabled = boolValue
case "LogConsumeEnabled":
common.LogConsumeEnabled = boolValue
case "DisplayInCurrencyEnabled":
common.DisplayInCurrencyEnabled = boolValue
case "DisplayTokenStatEnabled":
common.DisplayTokenStatEnabled = boolValue
}
}
switch key {
@@ -171,16 +185,26 @@ func updateOptionMap(key string, value string) (err error) {
common.TurnstileSecretKey = value
case "QuotaForNewUser":
common.QuotaForNewUser, _ = strconv.Atoi(value)
case "QuotaForInviter":
common.QuotaForInviter, _ = strconv.Atoi(value)
case "QuotaForInvitee":
common.QuotaForInvitee, _ = strconv.Atoi(value)
case "QuotaRemindThreshold":
common.QuotaRemindThreshold, _ = strconv.Atoi(value)
case "PreConsumedQuota":
common.PreConsumedQuota, _ = strconv.Atoi(value)
case "ModelRatio":
err = common.UpdateModelRatioByJSONString(value)
case "GroupRatio":
err = common.UpdateGroupRatioByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
case "ChatLink":
common.ChatLink = value
case "ChannelDisableThreshold":
common.ChannelDisableThreshold, _ = strconv.ParseFloat(value, 64)
case "QuotaPerUnit":
common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64)
}
return err
}

View File

@@ -2,7 +2,7 @@ package model
import (
"errors"
_ "gorm.io/driver/sqlite"
"fmt"
"one-api/common"
)
@@ -64,8 +64,9 @@ func Redeem(key string, userId int) (quota int, err error) {
redemption.Status = common.RedemptionCodeStatusUsed
err := redemption.SelectUpdate()
if err != nil {
common.SysError("更新兑换码状态失败:" + err.Error())
common.SysError("failed to update redemption status: " + err.Error())
}
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota)))
}()
return redemption.Quota, nil
}
@@ -84,7 +85,7 @@ func (redemption *Redemption) SelectUpdate() error {
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
var err error
err = DB.Model(redemption).Select("name", "status", "redeemed_time").Updates(redemption).Error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time").Updates(redemption).Error
return err
}

View File

@@ -3,7 +3,6 @@ package model
import (
"errors"
"fmt"
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
"one-api/common"
)
@@ -11,7 +10,7 @@ import (
type Token struct {
Id int `json:"id"`
UserId int `json:"user_id"`
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
Key string `json:"key" gorm:"type:char(48);uniqueIndex"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index" `
CreatedTime int64 `json:"created_time" gorm:"bigint"`
@@ -19,6 +18,7 @@ type Token struct {
ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired
RemainQuota int `json:"remain_quota" gorm:"default:0"`
UnlimitedQuota bool `json:"unlimited_quota" gorm:"default:false"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
}
func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
@@ -29,46 +29,45 @@ func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
}
func SearchUserTokens(userId int, keyword string) (tokens []*Token, err error) {
err = DB.Where("user_id = ?", userId).Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&tokens).Error
err = DB.Where("user_id = ?", userId).Where("name LIKE ?", keyword+"%").Find(&tokens).Error
return tokens, err
}
func ValidateUserToken(key string) (token *Token, err error) {
if key == "" {
return nil, errors.New("未提供 token")
return nil, errors.New("未提供令牌")
}
token = &Token{}
err = DB.Where("`key` = ?", key).First(token).Error
token, err = CacheGetTokenByKey(key)
if err == nil {
if token.Status != common.TokenStatusEnabled {
return nil, errors.New("该 token 状态不可用")
return nil, errors.New("该令牌状态不可用")
}
if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
token.Status = common.TokenStatusExpired
err := token.SelectUpdate()
if err != nil {
common.SysError("更新 token 状态失败:" + err.Error())
common.SysError("failed to update token status" + err.Error())
}
return nil, errors.New("该 token 已过期")
return nil, errors.New("该令牌已过期")
}
if !token.UnlimitedQuota && token.RemainQuota <= 0 {
token.Status = common.TokenStatusExhausted
err := token.SelectUpdate()
if err != nil {
common.SysError("更新 token 状态失败:" + err.Error())
common.SysError("failed to update token status" + err.Error())
}
return nil, errors.New("该 token 额度已用尽")
return nil, errors.New("该令牌额度已用尽")
}
go func() {
token.AccessedTime = common.GetTimestamp()
err := token.SelectUpdate()
if err != nil {
common.SysError("更新 token 失败:" + err.Error())
common.SysError("failed to update token" + err.Error())
}
}()
return token, nil
}
return nil, errors.New("无效的 token")
return nil, errors.New("无效的令牌")
}
func GetTokenByIds(id int, userId int) (*Token, error) {
@@ -132,7 +131,12 @@ func IncreaseTokenQuota(id int, quota int) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota + ?", quota)).Error
err = DB.Model(&Token{}).Where("id = ?", id).Updates(
map[string]interface{}{
"remain_quota": gorm.Expr("remain_quota + ?", quota),
"used_quota": gorm.Expr("used_quota - ?", quota),
},
).Error
return err
}
@@ -140,7 +144,12 @@ func DecreaseTokenQuota(id int, quota int) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota - ?", quota)).Error
err = DB.Model(&Token{}).Where("id = ?", id).Updates(
map[string]interface{}{
"remain_quota": gorm.Expr("remain_quota - ?", quota),
"used_quota": gorm.Expr("used_quota + ?", quota),
},
).Error
return err
}
@@ -168,7 +177,7 @@ func PreConsumeTokenQuota(tokenId int, quota int) (err error) {
go func() {
email, err := GetUserEmail(token.UserId)
if err != nil {
common.SysError("获取用户邮箱失败:" + err.Error())
common.SysError("failed to fetch user email: " + err.Error())
}
prompt := "您的额度即将用尽"
if noMoreQuota {
@@ -179,7 +188,7 @@ func PreConsumeTokenQuota(tokenId int, quota int) (err error) {
err = common.SendEmail(prompt, email,
fmt.Sprintf("%s当前剩余额度为 %d为了不影响您的使用请及时充值。<br/>充值链接:<a href='%s'>%s</a>", prompt, userQuota, topUpLink, topUpLink))
if err != nil {
common.SysError("发送邮件失败:" + err.Error())
common.SysError("failed to send email" + err.Error())
}
}
}()

View File

@@ -2,6 +2,7 @@ package model
import (
"errors"
"fmt"
"gorm.io/gorm"
"one-api/common"
"strings"
@@ -22,6 +23,11 @@ type User struct {
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
Quota int `json:"quota" gorm:"type:int;default:0"`
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
Group string `json:"group" gorm:"type:varchar(32);default:'default'"`
AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
}
func GetMaxUserId() int {
@@ -54,6 +60,15 @@ func GetUserById(id int, selectAll bool) (*User, error) {
return &user, err
}
func GetUserIdByAffCode(affCode string) (int, error) {
if affCode == "" {
return 0, errors.New("affCode 为空!")
}
var user User
err := DB.Select("id").First(&user, "aff_code = ?", affCode).Error
return user.Id, err
}
func DeleteUserById(id int) (err error) {
if id == 0 {
return errors.New("id 为空!")
@@ -62,7 +77,7 @@ func DeleteUserById(id int) (err error) {
return user.Delete()
}
func (user *User) Insert() error {
func (user *User) Insert(inviterId int) error {
var err error
if user.Password != "" {
user.Password, err = common.Password2Hash(user.Password)
@@ -72,8 +87,25 @@ func (user *User) Insert() error {
}
user.Quota = common.QuotaForNewUser
user.AccessToken = common.GetUUID()
err = DB.Create(user).Error
return err
user.AffCode = common.GetRandomString(4)
result := DB.Create(user)
if result.Error != nil {
return result.Error
}
if common.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", common.LogQuota(common.QuotaForNewUser)))
}
if inviterId != 0 {
if common.QuotaForInvitee > 0 {
_ = IncreaseUserQuota(user.Id, common.QuotaForInvitee)
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", common.LogQuota(common.QuotaForInvitee)))
}
if common.QuotaForInviter > 0 {
_ = IncreaseUserQuota(inviterId, common.QuotaForInviter)
RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", common.LogQuota(common.QuotaForInviter)))
}
}
return nil
}
func (user *User) Update(updatePassword bool) error {
@@ -188,7 +220,7 @@ func IsAdmin(userId int) bool {
var user User
err := DB.Where("id = ?", userId).Select("role").Find(&user).Error
if err != nil {
common.SysError("No such user " + err.Error())
common.SysError("no such user " + err.Error())
return false
}
return user.Role >= common.RoleAdminUser
@@ -201,7 +233,7 @@ func IsUserEnabled(userId int) bool {
var user User
err := DB.Where("id = ?", userId).Select("status").Find(&user).Error
if err != nil {
common.SysError("No such user " + err.Error())
common.SysError("no such user " + err.Error())
return false
}
return user.Status == common.UserStatusEnabled
@@ -224,11 +256,21 @@ func GetUserQuota(id int) (quota int, err error) {
return quota, err
}
func GetUserUsedQuota(id int) (quota int, err error) {
err = DB.Model(&User{}).Where("id = ?", id).Select("used_quota").Find(&quota).Error
return quota, err
}
func GetUserEmail(id int) (email string, err error) {
err = DB.Model(&User{}).Where("id = ?", id).Select("email").Find(&email).Error
return email, err
}
func GetUserGroup(id int) (group string, err error) {
err = DB.Model(&User{}).Where("id = ?", id).Select("`group`").Find(&group).Error
return group, err
}
func IncreaseUserQuota(id int, quota int) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
@@ -249,3 +291,15 @@ func GetRootUserEmail() (email string) {
DB.Model(&User{}).Where("role = ?", common.RoleRootUser).Select("email").Find(&email)
return email
}
func UpdateUserUsedQuotaAndRequestCount(id int, quota int) {
err := DB.Model(&User{}).Where("id = ?", id).Updates(
map[string]interface{}{
"used_quota": gorm.Expr("used_quota + ?", quota),
"request_count": gorm.Expr("request_count + ?", 1),
},
).Error
if err != nil {
common.SysError("failed to update user used quota and request count: " + err.Error())
}
}

View File

@@ -1,11 +1,16 @@
# File path: /etc/systemd/system/one-api.service
# sudo systemctl daemon-reload
# sudo systemctl start one-api
# sudo systemctl enable one-api
# sudo systemctl status one-api
[Unit]
Description=One API Service
After=network.target
[Service]
User=yourusername # 守护进程用户名
WorkingDirectory=/path/to/One-API # One API运行路径
ExecStart=/path/to/One-API/one-api --port 3000 --log-dir /path/to/One-API/logs # 端口
User=ubuntu # 注意修改用户名
WorkingDirectory=/path/to/one-api # 注意修改路径
ExecStart=/path/to/one-api/one-api --port 3000 --log-dir /path/to/one-api/logs # 注意修改路径和端口
Restart=always
RestartSec=5

View File

@@ -37,6 +37,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateAccessToken)
selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.POST("/topup", controller.TopUp)
}
@@ -63,6 +64,7 @@ func SetApiRouter(router *gin.Engine) {
{
channelRoute.GET("/", controller.GetAllChannels)
channelRoute.GET("/search", controller.SearchChannels)
channelRoute.GET("/models", controller.ListModels)
channelRoute.GET("/:id", controller.GetChannel)
channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel)
@@ -92,5 +94,15 @@ func SetApiRouter(router *gin.Engine) {
redemptionRoute.PUT("/", controller.UpdateRedemption)
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
}
logRoute := apiRouter.Group("/log")
logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)
groupRoute := apiRouter.Group("/group")
groupRoute.Use(middleware.AdminAuth())
{
groupRoute.GET("/", controller.GetGroups)
}
}
}

View File

@@ -8,12 +8,16 @@ import (
func SetRelayRouter(router *gin.Engine) {
// https://platform.openai.com/docs/api-reference/introduction
modelsRouter := router.Group("/v1/models")
modelsRouter.Use(middleware.TokenAuth())
{
modelsRouter.GET("/", controller.ListModels)
modelsRouter.GET("/:model", controller.RetrieveModel)
}
relayV1Router := router.Group("/v1")
relayV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
{
relayV1Router.GET("/models", controller.ListModels)
relayV1Router.GET("/models/:model", controller.RetrieveModel)
relayV1Router.POST("/completions", controller.RelayNotImplemented)
relayV1Router.POST("/completions", controller.Relay)
relayV1Router.POST("/chat/completions", controller.Relay)
relayV1Router.POST("/edits", controller.RelayNotImplemented)
relayV1Router.POST("/images/generations", controller.RelayNotImplemented)
@@ -33,6 +37,6 @@ func SetRelayRouter(router *gin.Engine) {
relayV1Router.POST("/fine-tunes/:id/cancel", controller.RelayNotImplemented)
relayV1Router.GET("/fine-tunes/:id/events", controller.RelayNotImplemented)
relayV1Router.DELETE("/models/:model", controller.RelayNotImplemented)
relayV1Router.POST("/moderations", controller.RelayNotImplemented)
relayV1Router.POST("/moderations", controller.Relay)
}
}

View File

@@ -7,7 +7,9 @@ import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/controller"
"one-api/middleware"
"strings"
)
func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
@@ -16,6 +18,10 @@ func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
router.Use(middleware.Cache())
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
router.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.RequestURI, "/v1") {
controller.RelayNotFound(c)
return
}
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
})

View File

@@ -7,7 +7,7 @@
<meta name="theme-color" content="#ffffff" />
<meta
name="description"
content="Web site created using create-react-app"
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
/>
<title>One API</title>
</head>

View File

@@ -22,6 +22,8 @@ import EditChannel from './pages/Channel/EditChannel';
import Redemption from './pages/Redemption';
import EditRedemption from './pages/Redemption/EditRedemption';
import TopUp from './pages/TopUp';
import Log from './pages/Log';
import Chat from './pages/Chat';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
@@ -46,6 +48,13 @@ function App() {
localStorage.setItem('system_name', data.system_name);
localStorage.setItem('logo', data.logo);
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('quota_per_unit', data.quota_per_unit);
localStorage.setItem('display_in_currency', data.display_in_currency);
if (data.chat_link) {
localStorage.setItem('chat_link', data.chat_link);
} else {
localStorage.removeItem('chat_link');
}
if (
data.version !== process.env.REACT_APP_VERSION &&
data.version !== 'v0.0.0' &&
@@ -250,6 +259,14 @@ function App() {
</PrivateRoute>
}
/>
<Route
path='/log'
element={
<PrivateRoute>
<Log />
</PrivateRoute>
}
/>
<Route
path='/about'
element={
@@ -258,6 +275,14 @@ function App() {
</Suspense>
}
/>
<Route
path='/chat'
element={
<Suspense fallback={<Loading></Loading>}>
<Chat />
</Suspense>
}
/>
<Route path='*' element={NotFound} />
</Routes>
);

View File

@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { API, showError, showInfo, showSuccess, timestamp2string } from '../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber } from '../helpers/render';
function renderTimestamp(timestamp) {
return (
@@ -26,6 +27,22 @@ function renderType(type) {
return <Label basic color={type2label[type].color}>{type2label[type].text}</Label>;
}
function renderBalance(type, balance) {
switch (type) {
case 1: // OpenAI
case 8: // 自定义
return <span>${balance.toFixed(2)}</span>;
case 5: // OpenAI-SB
return <span>¥{(balance / 10000).toFixed(2)}</span>;
case 10: // AI Proxy
return <span>{renderNumber(balance)}</span>;
case 12: // API2GPT
return <span>¥{balance.toFixed(2)}</span>;
default:
return <span>不支持</span>;
}
}
const ChannelsTable = () => {
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true);
@@ -221,9 +238,17 @@ const ChannelsTable = () => {
if (channels.length === 0) return;
setLoading(true);
let sortedChannels = [...channels];
sortedChannels.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (typeof sortedChannels[0][key] === 'string'){
sortedChannels.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
} else {
sortedChannels.sort((a, b) => {
if (a[key] === b[key]) return 0;
if (a[key] > b[key]) return -1;
if (a[key] < b[key]) return 1;
});
}
if (sortedChannels[0].id === channels[0].id) {
sortedChannels.reverse();
}
@@ -238,14 +263,14 @@ const ChannelsTable = () => {
icon='search'
fluid
iconPosition='left'
placeholder='搜索渠道的 ID 和名称 ...'
placeholder='搜索渠道的 ID,名称和密钥 ...'
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Table basic>
<Table basic compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@@ -264,6 +289,14 @@ const ChannelsTable = () => {
>
名称
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortChannel('group');
}}
>
分组
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
@@ -312,6 +345,7 @@ const ChannelsTable = () => {
<Table.Row key={channel.id}>
<Table.Cell>{channel.id}</Table.Cell>
<Table.Cell>{channel.name ? channel.name : '无'}</Table.Cell>
<Table.Cell>{renderGroup(channel.group)}</Table.Cell>
<Table.Cell>{renderType(channel.type)}</Table.Cell>
<Table.Cell>{renderStatus(channel.status)}</Table.Cell>
<Table.Cell>
@@ -326,7 +360,7 @@ const ChannelsTable = () => {
<Popup
content={channel.balance_updated_time ? renderTimestamp(channel.balance_updated_time) : '未更新'}
key={channel.id}
trigger={<span>${channel.balance.toFixed(2)}</span>}
trigger={renderBalance(channel.type, channel.balance)}
basic
/>
</Table.Cell>
@@ -398,14 +432,15 @@ const ChannelsTable = () => {
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='7'>
<Table.HeaderCell colSpan='8'>
<Button size='small' as={Link} to='/channel/add' loading={loading}>
添加新的渠道
</Button>
<Button size='small' loading={loading} onClick={testAllChannels}>
测试所有已启用通道
</Button>
<Button size='small' onClick={updateAllChannelsBalance} loading={updatingBalance}>更新所有已启用通道余额</Button>
<Button size='small' onClick={updateAllChannelsBalance}
loading={loading || updatingBalance}>更新所有已启用通道余额</Button>
<Pagination
floated='right'
activePage={activePage}

View File

@@ -1,11 +1,31 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Container, Segment } from 'semantic-ui-react';
import { getFooterHTML, getSystemName } from '../helpers';
const Footer = () => {
const systemName = getSystemName();
const footer = getFooterHTML();
const [footer, setFooter] = useState(getFooterHTML());
let remainCheckTimes = 5;
const loadFooter = () => {
let footer_html = localStorage.getItem('footer_html');
if (footer_html) {
setFooter(footer_html);
}
};
useEffect(() => {
const timer = setInterval(() => {
if (remainCheckTimes <= 0) {
clearInterval(timer);
return;
}
remainCheckTimes--;
loadFooter();
}, 200);
return () => clearTimeout(timer);
}, []);
return (
<Segment vertical>

View File

@@ -7,52 +7,65 @@ import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../
import '../index.css';
// Header Buttons
const headerButtons = [
let headerButtons = [
{
name: '首页',
to: '/',
icon: 'home',
icon: 'home'
},
{
name: '渠道',
to: '/channel',
icon: 'sitemap',
admin: true,
admin: true
},
{
name: '令牌',
to: '/token',
icon: 'key',
icon: 'key'
},
{
name: '兑换',
to: '/redemption',
icon: 'dollar sign',
admin: true,
admin: true
},
{
name: '充值',
to: '/topup',
icon: 'cart',
icon: 'cart'
},
{
name: '用户',
to: '/user',
icon: 'user',
admin: true,
admin: true
},
{
name: '日志',
to: '/log',
icon: 'book'
},
{
name: '设置',
to: '/setting',
icon: 'setting',
icon: 'setting'
},
{
name: '关于',
to: '/about',
icon: 'info circle',
},
icon: 'info circle'
}
];
if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, {
name: '聊天',
to: '/chat',
icon: 'comments'
});
}
const Header = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
@@ -107,11 +120,11 @@ const Header = () => {
style={
showSidebar
? {
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px',
}
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px'
}
: { borderTop: 'none', height: '52px' }
}
>

View File

@@ -0,0 +1,256 @@
import React, { useEffect, useState } from 'react';
import { Button, Label, Pagination, Select, Table } from 'semantic-ui-react';
import { API, isAdmin, showError, timestamp2string } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
function renderTimestamp(timestamp) {
return (
<>
{timestamp2string(timestamp)}
</>
);
}
const MODE_OPTIONS = [
{ key: 'all', text: '全部用户', value: 'all' },
{ key: 'self', text: '当前用户', value: 'self' },
];
const LOG_OPTIONS = [
{ key: '0', text: '全部', value: 0 },
{ key: '1', text: '充值', value: 1 },
{ key: '2', text: '消费', value: 2 },
{ key: '3', text: '管理', value: 3 },
{ key: '4', text: '系统', value: 4 }
];
function renderType(type) {
switch (type) {
case 1:
return <Label basic color='green'> 充值 </Label>;
case 2:
return <Label basic color='olive'> 消费 </Label>;
case 3:
return <Label basic color='orange'> 管理 </Label>;
case 4:
return <Label basic color='purple'> 系统 </Label>;
default:
return <Label basic color='black'> 未知 </Label>;
}
}
const LogsTable = () => {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [logType, setLogType] = useState(0);
const [mode, setMode] = useState('self'); // all, self
const showModePanel = isAdmin();
const loadLogs = async (startIdx) => {
let url = `/api/log/self/?p=${startIdx}&type=${logType}`;
if (mode === 'all') {
url = `/api/log/?p=${startIdx}&type=${logType}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogs(data);
} else {
let newLogs = logs;
newLogs.push(...data);
setLogs(newLogs);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadLogs(activePage - 1);
}
setActivePage(activePage);
})();
};
const refresh = async () => {
setLoading(true);
await loadLogs(0);
};
useEffect(() => {
loadLogs(0)
.then()
.catch((reason) => {
showError(reason);
});
}, []);
useEffect(() => {
refresh().then();
}, [mode, logType]);
const searchLogs = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadLogs(0);
setActivePage(1);
return;
}
setSearching(true);
const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);
const { success, message, data } = res.data;
if (success) {
setLogs(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortLog = (key) => {
if (logs.length === 0) return;
setLoading(true);
let sortedLogs = [...logs];
sortedLogs.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (sortedLogs[0].id === logs[0].id) {
sortedLogs.reverse();
}
setLogs(sortedLogs);
setLoading(false);
};
return (
<>
<Table basic>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('created_time');
}}
width={3}
>
时间
</Table.HeaderCell>
{
showModePanel && (
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('user_id');
}}
width={1}
>
用户
</Table.HeaderCell>
)
}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('type');
}}
width={2}
>
类型
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('content');
}}
width={showModePanel ? 10 : 11}
>
详情
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{logs
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((log, idx) => {
if (log.deleted) return <></>;
return (
<Table.Row key={log.created_at}>
<Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell>
{
showModePanel && (
<Table.Cell><Label>{log.user_id}</Label></Table.Cell>
)
}
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>{log.content}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={showModePanel ? '5' : '4'}>
{
showModePanel && (
<Select
placeholder='选择模式'
options={MODE_OPTIONS}
style={{ marginRight: '8px' }}
name='mode'
value={mode}
onChange={(e, { name, value }) => {
setMode(value);
}}
/>
)
}
<Select
placeholder='选择明细分类'
options={LOG_OPTIONS}
style={{ marginRight: '8px' }}
name='logType'
value={logType}
onChange={(e, { name, value }) => {
setLogType(value);
}}
/>
<Button size='small' onClick={refresh} loading={loading}>刷新</Button>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(logs.length / ITEMS_PER_PAGE) +
(logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};
export default LogsTable;

View File

@@ -0,0 +1,312 @@
import React, { useEffect, useState } from 'react';
import { Divider, Form, Grid, Header } from 'semantic-ui-react';
import { API, showError, verifyJSON } from '../helpers';
const OperationSetting = () => {
let [inputs, setInputs] = useState({
QuotaForNewUser: 0,
QuotaForInviter: 0,
QuotaForInvitee: 0,
QuotaRemindThreshold: 0,
PreConsumedQuota: 0,
ModelRatio: '',
GroupRatio: '',
TopUpLink: '',
ChatLink: '',
QuotaPerUnit: 0,
AutomaticDisableChannelEnabled: '',
ChannelDisableThreshold: 0,
LogConsumeEnabled: '',
DisplayInCurrencyEnabled: '',
DisplayTokenStatEnabled: ''
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key === 'ModelRatio' || item.key === 'GroupRatio') {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
newInputs[item.key] = item.value;
});
setInputs(newInputs);
setOriginInputs(newInputs);
} else {
showError(message);
}
};
useEffect(() => {
getOptions().then();
}, []);
const updateOption = async (key, value) => {
setLoading(true);
if (key.endsWith('Enabled')) {
value = inputs[key] === 'true' ? 'false' : 'true';
}
const res = await API.put('/api/option/', {
key,
value
});
const { success, message } = res.data;
if (success) {
setInputs((inputs) => ({ ...inputs, [key]: value }));
} else {
showError(message);
}
setLoading(false);
};
const handleInputChange = async (e, { name, value }) => {
if (name.endsWith('Enabled')) {
await updateOption(name, value);
} else {
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
};
const submitConfig = async (group) => {
switch (group) {
case 'monitor':
if (originInputs['AutomaticDisableChannelEnabled'] !== inputs.AutomaticDisableChannelEnabled) {
await updateOption('AutomaticDisableChannelEnabled', inputs.AutomaticDisableChannelEnabled);
}
if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) {
await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold);
}
if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) {
await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold);
}
break;
case 'ratio':
if (originInputs['ModelRatio'] !== inputs.ModelRatio) {
if (!verifyJSON(inputs.ModelRatio)) {
showError('模型倍率不是合法的 JSON 字符串');
return;
}
await updateOption('ModelRatio', inputs.ModelRatio);
}
if (originInputs['GroupRatio'] !== inputs.GroupRatio) {
if (!verifyJSON(inputs.GroupRatio)) {
showError('分组倍率不是合法的 JSON 字符串');
return;
}
await updateOption('GroupRatio', inputs.GroupRatio);
}
break;
case 'quota':
if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
}
if (originInputs['QuotaForInvitee'] !== inputs.QuotaForInvitee) {
await updateOption('QuotaForInvitee', inputs.QuotaForInvitee);
}
if (originInputs['QuotaForInviter'] !== inputs.QuotaForInviter) {
await updateOption('QuotaForInviter', inputs.QuotaForInviter);
}
if (originInputs['PreConsumedQuota'] !== inputs.PreConsumedQuota) {
await updateOption('PreConsumedQuota', inputs.PreConsumedQuota);
}
break;
case 'general':
if (originInputs['TopUpLink'] !== inputs.TopUpLink) {
await updateOption('TopUpLink', inputs.TopUpLink);
}
if (originInputs['ChatLink'] !== inputs.ChatLink) {
await updateOption('ChatLink', inputs.ChatLink);
}
if (originInputs['QuotaPerUnit'] !== inputs.QuotaPerUnit) {
await updateOption('QuotaPerUnit', inputs.QuotaPerUnit);
}
break;
}
};
return (
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>
通用设置
</Header>
<Form.Group widths={3}>
<Form.Input
label='充值链接'
name='TopUpLink'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.TopUpLink}
type='link'
placeholder='例如发卡网站的购买链接'
/>
<Form.Input
label='聊天页面链接'
name='ChatLink'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChatLink}
type='link'
placeholder='例如 ChatGPT Next Web 的部署地址'
/>
<Form.Input
label='单位美元额度'
name='QuotaPerUnit'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaPerUnit}
type='number'
step='0.01'
placeholder='一单位货币能兑换的额度'
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.LogConsumeEnabled === 'true'}
label='启用额度消费日志记录'
name='LogConsumeEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.DisplayInCurrencyEnabled === 'true'}
label='以货币形式显示额度'
name='DisplayInCurrencyEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.DisplayTokenStatEnabled === 'true'}
label='Billing 相关 API 显示令牌额度而非用户额度'
name='DisplayTokenStatEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('general').then();
}}>保存通用设置</Form.Button>
<Divider />
<Header as='h3'>
监控设置
</Header>
<Form.Group widths={3}>
<Form.Input
label='最长响应时间'
name='ChannelDisableThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChannelDisableThreshold}
type='number'
min='0'
placeholder='单位秒,当运行通道全部测试时,超过此时间将自动禁用通道'
/>
<Form.Input
label='额度提醒阈值'
name='QuotaRemindThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaRemindThreshold}
type='number'
min='0'
placeholder='低于此额度时将发送邮件提醒用户'
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.AutomaticDisableChannelEnabled === 'true'}
label='失败时自动禁用通道'
name='AutomaticDisableChannelEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('monitor').then();
}}>保存监控设置</Form.Button>
<Divider />
<Header as='h3'>
额度设置
</Header>
<Form.Group widths={4}>
<Form.Input
label='新用户初始额度'
name='QuotaForNewUser'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForNewUser}
type='number'
min='0'
placeholder='例如100'
/>
<Form.Input
label='请求预扣费额度'
name='PreConsumedQuota'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.PreConsumedQuota}
type='number'
min='0'
placeholder='请求结束后多退少补'
/>
<Form.Input
label='邀请新用户奖励额度'
name='QuotaForInviter'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInviter}
type='number'
min='0'
placeholder='例如2000'
/>
<Form.Input
label='新用户使用邀请码奖励额度'
name='QuotaForInvitee'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInvitee}
type='number'
min='0'
placeholder='例如1000'
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('quota').then();
}}>保存额度设置</Form.Button>
<Divider />
<Header as='h3'>
倍率设置
</Header>
<Form.Group widths='equal'>
<Form.TextArea
label='模型倍率'
name='ModelRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.ModelRatio}
placeholder='为一个 JSON 文本,键为模型名称,值为倍率'
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label='分组倍率'
name='GroupRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.GroupRatio}
placeholder='为一个 JSON 文本,键为分组名称,值为倍率'
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('ratio').then();
}}>保存倍率设置</Form.Button>
</Form>
</Grid.Column>
</Grid>
);
};
export default OperationSetting;

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Modal } from 'semantic-ui-react';
import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react';
import { API, showError, showSuccess } from '../helpers';
import { marked } from 'marked';
@@ -10,13 +10,13 @@ const OtherSetting = () => {
About: '',
SystemName: '',
Logo: '',
HomePageContent: '',
HomePageContent: ''
});
let [loading, setLoading] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [updateData, setUpdateData] = useState({
tag_name: '',
content: '',
content: ''
});
const getOptions = async () => {
@@ -43,7 +43,7 @@ const OtherSetting = () => {
setLoading(true);
const res = await API.put('/api/option/', {
key,
value,
value
});
const { success, message } = res.data;
if (success) {
@@ -97,7 +97,7 @@ const OtherSetting = () => {
} else {
setUpdateData({
tag_name: tag_name,
content: marked.parse(body),
content: marked.parse(body)
});
setShowUpdateModal(true);
}
@@ -153,7 +153,7 @@ const OtherSetting = () => {
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={()=>submitOption('HomePageContent')}>保存首页内容</Form.Button>
<Form.Button onClick={() => submitOption('HomePageContent')}>保存首页内容</Form.Button>
<Form.Group widths='equal'>
<Form.TextArea
label='关于'
@@ -165,6 +165,7 @@ const OtherSetting = () => {
/>
</Form.Group>
<Form.Button onClick={submitAbout}>保存关于</Form.Button>
<Message>移除 One API 的版权标识必须首先获得授权项目维护需要花费大量精力如果本项目对你有意义请主动支持本项目</Message>
<Form.Group widths='equal'>
<Form.Input
label='页脚'

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import { API, copy, showError, showSuccess } from '../helpers';
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
import { useSearchParams } from 'react-router-dom';
const PasswordResetConfirm = () => {
@@ -33,7 +33,7 @@ const PasswordResetConfirm = () => {
if (success) {
let password = res.data.data;
await copy(password);
showSuccess(`密码已重置并已复制到剪贴板:${password}`);
showNotice(`密码已重置并已复制到剪贴板:${password}`);
} else {
showError(message);
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess } from '../helpers';
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const PersonalSetting = () => {
@@ -45,6 +45,18 @@ const PersonalSetting = () => {
}
};
const getAffLink = async () => {
const res = await API.get('/api/user/aff');
const { success, message, data } = res.data;
if (success) {
let link = `${window.location.origin}/register?aff=${data}`;
await copy(link);
showNotice(`邀请链接已复制到剪切板:${link}`);
} else {
showError(message);
}
};
const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
@@ -110,6 +122,7 @@ const PersonalSetting = () => {
更新个人信息
</Button>
<Button onClick={generateAccessToken}>生成系统访问令牌</Button>
<Button onClick={getAffLink}>复制邀请链接</Button>
<Divider />
<Header as='h3'>账号绑定</Header>
{

View File

@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) {
return (
@@ -152,7 +153,7 @@ const RedemptionsTable = () => {
/>
</Form>
<Table basic>
<Table basic compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@@ -220,7 +221,7 @@ const RedemptionsTable = () => {
<Table.Cell>{redemption.id}</Table.Cell>
<Table.Cell>{redemption.name ? redemption.name : '无'}</Table.Cell>
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
<Table.Cell>{redemption.quota}</Table.Cell>
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell>
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell>
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} </Table.Cell>
<Table.Cell>

View File

@@ -27,6 +27,10 @@ const RegisterForm = () => {
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
const logo = getLogo();
let affCode = new URLSearchParams(window.location.search).get('aff');
if (affCode) {
localStorage.setItem('aff', affCode);
}
useEffect(() => {
let status = localStorage.getItem('status');
@@ -63,6 +67,10 @@ const RegisterForm = () => {
return;
}
setLoading(true);
if (!affCode) {
affCode = localStorage.getItem('aff');
}
inputs.aff_code = affCode;
const res = await API.post(
`/api/user/register?turnstile=${turnstileToken}`,
inputs

View File

@@ -26,13 +26,6 @@ const SystemSetting = () => {
TurnstileSiteKey: '',
TurnstileSecretKey: '',
RegisterEnabled: '',
QuotaForNewUser: 0,
QuotaRemindThreshold: 0,
PreConsumedQuota: 0,
ModelRatio: '',
TopUpLink: '',
AutomaticDisableChannelEnabled: '',
ChannelDisableThreshold: 0,
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
@@ -66,7 +59,6 @@ const SystemSetting = () => {
case 'WeChatAuthEnabled':
case 'TurnstileCheckEnabled':
case 'RegisterEnabled':
case 'AutomaticDisableChannelEnabled':
value = inputs[key] === 'true' ? 'false' : 'true';
break;
default:
@@ -96,12 +88,7 @@ const SystemSetting = () => {
name === 'WeChatServerToken' ||
name === 'WeChatAccountQRCodeImageURL' ||
name === 'TurnstileSiteKey' ||
name === 'TurnstileSecretKey' ||
name === 'QuotaForNewUser' ||
name === 'QuotaRemindThreshold' ||
name === 'PreConsumedQuota' ||
name === 'ModelRatio' ||
name === 'TopUpLink'
name === 'TurnstileSecretKey'
) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
} else {
@@ -114,28 +101,6 @@ const SystemSetting = () => {
await updateOption('ServerAddress', ServerAddress);
};
const submitOperationConfig = async () => {
if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
}
if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) {
await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold);
}
if (originInputs['PreConsumedQuota'] !== inputs.PreConsumedQuota) {
await updateOption('PreConsumedQuota', inputs.PreConsumedQuota);
}
if (originInputs['ModelRatio'] !== inputs.ModelRatio) {
if (!verifyJSON(inputs.ModelRatio)) {
showError('模型倍率不是合法的 JSON 字符串');
return;
}
await updateOption('ModelRatio', inputs.ModelRatio);
}
if (originInputs['TopUpLink'] !== inputs.TopUpLink) {
await updateOption('TopUpLink', inputs.TopUpLink);
}
};
const submitSMTP = async () => {
if (originInputs['SMTPServer'] !== inputs.SMTPServer) {
await updateOption('SMTPServer', inputs.SMTPServer);
@@ -274,87 +239,6 @@ const SystemSetting = () => {
/>
</Form.Group>
<Divider />
<Header as='h3'>
运营设置
</Header>
<Form.Group widths={4}>
<Form.Input
label='新用户初始配额'
name='QuotaForNewUser'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForNewUser}
type='number'
min='0'
placeholder='例如100'
/>
<Form.Input
label='充值链接'
name='TopUpLink'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.TopUpLink}
type='link'
placeholder='例如发卡网站的购买链接'
/>
<Form.Input
label='额度提醒阈值'
name='QuotaRemindThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaRemindThreshold}
type='number'
min='0'
placeholder='低于此额度时将发送邮件提醒用户'
/>
<Form.Input
label='请求预扣费额度'
name='PreConsumedQuota'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.PreConsumedQuota}
type='number'
min='0'
placeholder='请求结束后多退少补'
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label='模型倍率'
name='ModelRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.ModelRatio}
placeholder='为一个 JSON 文本,键为模型名称,值为倍率'
/>
</Form.Group>
<Form.Button onClick={submitOperationConfig}>保存运营设置</Form.Button>
<Divider />
<Header as='h3'>
监控设置
</Header>
<Form.Group widths={3}>
<Form.Input
label='最长响应时间'
name='ChannelDisableThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChannelDisableThreshold}
type='number'
min='0'
placeholder='单位秒,当运行通道全部测试时,超过此时间将自动禁用通道'
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.AutomaticDisableChannelEnabled === 'true'}
label='失败时自动禁用通道'
name='AutomaticDisableChannelEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Divider />
<Header as='h3'>
配置 SMTP
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader>

View File

@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) {
return (
@@ -154,24 +155,16 @@ const TokensTable = () => {
icon='search'
fluid
iconPosition='left'
placeholder='搜索令牌的 ID 和名称 ...'
placeholder='搜索令牌的名称 ...'
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Table basic>
<Table basic compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('id');
}}
>
ID
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
@@ -188,13 +181,21 @@ const TokensTable = () => {
>
状态
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('used_quota');
}}
>
已用额度
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortToken('remain_quota');
}}
>
额度
剩余额度
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -226,10 +227,10 @@ const TokensTable = () => {
if (token.deleted) return <></>;
return (
<Table.Row key={token.id}>
<Table.Cell>{token.id}</Table.Cell>
<Table.Cell>{token.name ? token.name : '无'}</Table.Cell>
<Table.Cell>{renderStatus(token.status)}</Table.Cell>
<Table.Cell>{token.unlimited_quota ? '无限制' : token.remain_quota}</Table.Cell>
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell>
<Table.Cell>{token.unlimited_quota ? '无限制' : renderQuota(token.remain_quota, 2)}</Table.Cell>
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
<Table.Cell>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell>
<Table.Cell>
@@ -238,11 +239,12 @@ const TokensTable = () => {
size={'small'}
positive
onClick={async () => {
if (await copy(token.key)) {
let key = "sk-" + token.key;
if (await copy(key)) {
showSuccess('已复制到剪贴板!');
} else {
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
setSearchKeyword(token.key);
setSearchKeyword(key);
}
}}
>
@@ -295,7 +297,7 @@ const TokensTable = () => {
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='8'>
<Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/token/add' loading={loading}>
添加新的令牌
</Button>

View File

@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderText } from '../helpers/render';
import { renderGroup, renderNumber, renderQuota, renderText } from '../helpers/render';
function renderRole(role) {
switch (role) {
@@ -156,7 +156,7 @@ const UsersTable = () => {
/>
</Form>
<Table basic>
<Table basic compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@@ -175,6 +175,14 @@ const UsersTable = () => {
>
用户名
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('group');
}}
>
分组
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
@@ -189,7 +197,7 @@ const UsersTable = () => {
sortUser('quota');
}}
>
剩余额度
统计信息
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -231,8 +239,15 @@ const UsersTable = () => {
hoverable
/>
</Table.Cell>
<Table.Cell>{user.email ? renderText(user.email, 30) : '无'}</Table.Cell>
<Table.Cell>{user.quota}</Table.Cell>
<Table.Cell>{renderGroup(user.group)}</Table.Cell>
<Table.Cell>
{user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}
</Table.Cell>
<Table.Cell>
<Popup content='剩余额度' trigger={<Label>{renderQuota(user.quota)}</Label>} />
<Popup content='已用额度' trigger={<Label>{renderQuota(user.used_quota)}</Label>} />
<Popup content='请求次数' trigger={<Label>{renderNumber(user.request_count)}</Label>} />
</Table.Cell>
<Table.Cell>{renderRole(user.role)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
<Table.Cell>
@@ -293,7 +308,6 @@ const UsersTable = () => {
size={'small'}
as={Link}
to={'/user/edit/' + user.id}
disabled={user.role === 100}
>
编辑
</Button>
@@ -306,7 +320,7 @@ const UsersTable = () => {
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='7'>
<Table.HeaderCell colSpan='8'>
<Button size='small' as={Link} to='/user/add' loading={loading}>
添加新的用户
</Button>

View File

@@ -8,5 +8,6 @@ export const CHANNEL_OPTIONS = [
{ key: 6, text: 'OpenAI Max', value: 6, color: 'violet' },
{ key: 7, text: 'OhMyGPT', value: 7, color: 'purple' },
{ key: 9, text: 'AI.LS', value: 9, color: 'yellow' },
{ key: 10, text: 'AI Proxy', value: 10, color: 'purple' }
{ key: 10, text: 'AI Proxy', value: 10, color: 'purple' },
{ key: 12, text: 'API2GPT', value: 12, color: 'blue' }
];

View File

@@ -1,6 +1,58 @@
import { Label } from 'semantic-ui-react';
export function renderText(text, limit) {
if (text.length > limit) {
return text.slice(0, limit - 3) + '...';
}
return text;
}
export function renderGroup(group) {
if (group === '') {
return <Label>default</Label>;
}
let groups = group.split(',');
groups.sort();
return <>
{groups.map((group) => {
if (group === 'vip' || group === 'pro') {
return <Label color='yellow'>{group}</Label>;
} else if (group === 'svip' || group === 'premium') {
return <Label color='red'>{group}</Label>;
}
return <Label>{group}</Label>;
})}
</>;
}
export function renderNumber(num) {
if (num >= 1000000000) {
return (num / 1000000000).toFixed(1) + 'B';
} else if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 10000) {
return (num / 1000).toFixed(1) + 'k';
} else {
return num;
}
}
export function renderQuota(quota, digits = 2) {
let quotaPerUnit = localStorage.getItem('quota_per_unit');
let displayInCurrency = localStorage.getItem('display_in_currency');
quotaPerUnit = parseFloat(quotaPerUnit);
displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) {
return '$' + (quota / quotaPerUnit).toFixed(digits);
}
return renderNumber(quota);
}
export function renderQuotaWithPrompt(quota, digits) {
let displayInCurrency = localStorage.getItem('display_in_currency');
displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) {
return `(等价金额:${renderQuota(quota, digits)}`;
}
return '';
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers';
import { API, showError, showInfo, showSuccess } from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants';
const EditChannel = () => {
@@ -14,12 +14,17 @@ const EditChannel = () => {
type: 1,
key: '',
base_url: '',
other: ''
other: '',
models: [],
groups: ['default']
};
const [batch, setBatch] = useState(false);
const [inputs, setInputs] = useState(originInputs);
const [modelOptions, setModelOptions] = useState([]);
const [groupOptions, setGroupOptions] = useState([]);
const [basicModels, setBasicModels] = useState([]);
const [fullModels, setFullModels] = useState([]);
const handleInputChange = (e, { name, value }) => {
console.log(name, value);
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
@@ -27,21 +32,68 @@ const EditChannel = () => {
let res = await API.get(`/api/channel/${channelId}`);
const { success, message, data } = res.data;
if (success) {
data.password = '';
if (data.models === '') {
data.models = [];
} else {
data.models = data.models.split(',');
}
if (data.group === '') {
data.groups = [];
} else {
data.groups = data.group.split(',');
}
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
const fetchModels = async () => {
try {
let res = await API.get(`/api/channel/models`);
setModelOptions(res.data.data.map((model) => ({
key: model.id,
text: model.id,
value: model.id
})));
setFullModels(res.data.data.map((model) => model.id));
setBasicModels(res.data.data.filter((model) => !model.id.startsWith('gpt-4')).map((model) => model.id));
} catch (error) {
showError(error.message);
}
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({
key: group,
text: group,
value: group
})));
} catch (error) {
showError(error.message);
}
};
useEffect(() => {
if (isEdit) {
loadChannel().then();
}
fetchModels().then();
fetchGroups().then();
}, []);
const submit = async () => {
if (!isEdit && (inputs.name === '' || inputs.key === '')) return;
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
showInfo('请填写渠道名称和渠道密钥!');
return;
}
if (inputs.models.length === 0) {
showInfo('请至少选择一个模型!');
return;
}
let localInputs = inputs;
if (localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
@@ -50,6 +102,8 @@ const EditChannel = () => {
localInputs.other = '2023-03-15-preview';
}
let res;
localInputs.models = localInputs.models.join(',');
localInputs.group = localInputs.groups.join(',');
if (isEdit) {
res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) });
} else {
@@ -86,7 +140,9 @@ const EditChannel = () => {
inputs.type === 3 && (
<>
<Message>
注意<strong>模型部署名称必须和模型名称保持一致</strong> One API model
注意<strong>模型部署名称必须和模型名称保持一致</strong> One API model
参数替换为你的部署名称模型名称中的点会被剔除<a target='_blank'
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a>
</Message>
<Form.Field>
<Form.Input
@@ -125,6 +181,20 @@ const EditChannel = () => {
</Form.Field>
)
}
{
inputs.type !== 3 && inputs.type !== 8 && (
<Form.Field>
<Form.Input
label='镜像'
name='base_url'
placeholder={'请输入镜像站地址格式为https://domain.com可不填不填则使用渠道默认值'}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
<Form.Field>
<Form.Input
label='名称'
@@ -135,6 +205,47 @@ const EditChannel = () => {
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Dropdown
label='分组'
placeholder={'请选择分组'}
name='groups'
fluid
multiple
selection
allowAdditions
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
onChange={handleInputChange}
value={inputs.groups}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
<Form.Field>
<Form.Dropdown
label='模型'
placeholder={'请选择该通道所支持的模型'}
name='models'
fluid
multiple
selection
onChange={handleInputChange}
value={inputs.models}
autoComplete='new-password'
options={modelOptions}
/>
</Form.Field>
<div style={{ lineHeight: '40px', marginBottom: '12px' }}>
<Button type={'button'} onClick={() => {
handleInputChange(null, { name: 'models', value: basicModels });
}}>填入基础模型</Button>
<Button type={'button'} onClick={() => {
handleInputChange(null, { name: 'models', value: fullModels });
}}>填入所有模型</Button>
<Button type={'button'} onClick={() => {
handleInputChange(null, { name: 'models', value: [] });
}}>清除所有模型</Button>
</div>
{
batch ? <Form.Field>
<Form.TextArea
@@ -154,7 +265,7 @@ const EditChannel = () => {
onChange={handleInputChange}
value={inputs.key}
autoComplete='new-password'
/>
/>
</Form.Field>
}
{
@@ -167,7 +278,7 @@ const EditChannel = () => {
/>
)
}
<Button onClick={submit}>提交</Button>
<Button positive onClick={submit}>提交</Button>
</Form>
</Segment>
</>

View File

@@ -0,0 +1,15 @@
import React from 'react';
const Chat = () => {
const chatLink = localStorage.getItem('chat_link');
return (
<iframe
src={chatLink}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
);
};
export default Chat;

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Header, Segment } from 'semantic-ui-react';
import LogsTable from '../../components/LogsTable';
const Token = () => (
<>
<Segment>
<Header as='h3'>额度明细</Header>
<LogsTable />
</Segment>
</>
);
export default Token;

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
const EditRedemption = () => {
const params = useParams();
@@ -10,7 +11,7 @@ const EditRedemption = () => {
const [loading, setLoading] = useState(isEdit);
const originInputs = {
name: '',
quota: 100,
quota: 100000,
count: 1
};
const [inputs, setInputs] = useState(originInputs);
@@ -87,7 +88,7 @@ const EditRedemption = () => {
</Form.Field>
<Form.Field>
<Form.Input
label='额度'
label={`额度${renderQuotaWithPrompt(quota)}`}
name='quota'
placeholder={'请输入单个兑换码中包含的额度'}
onChange={handleInputChange}
@@ -111,7 +112,7 @@ const EditRedemption = () => {
</Form.Field>
</>
}
<Button onClick={submit}>提交</Button>
<Button positive onClick={submit}>提交</Button>
</Form>
</Segment>
</>

View File

@@ -4,6 +4,7 @@ import SystemSetting from '../../components/SystemSetting';
import { isRoot } from '../../helpers';
import OtherSetting from '../../components/OtherSetting';
import PersonalSetting from '../../components/PersonalSetting';
import OperationSetting from '../../components/OperationSetting';
const Setting = () => {
let panes = [
@@ -18,6 +19,14 @@ const Setting = () => {
];
if (isRoot()) {
panes.push({
menuItem: '运营设置',
render: () => (
<Tab.Pane attached={false}>
<OperationSetting />
</Tab.Pane>
)
});
panes.push({
menuItem: '系统设置',
render: () => (

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
const EditToken = () => {
const params = useParams();
@@ -106,22 +107,6 @@ const EditToken = () => {
required={!isEdit}
/>
</Form.Field>
<Message>注意令牌的额度仅用于限制令牌本身的最大额度使用量实际的使用受到账户的剩余额度限制</Message>
<Form.Field>
<Form.Input
label='额度'
name='remain_quota'
placeholder={'请输入额度'}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button type={'button'} style={{ marginBottom: '14px' }} onClick={() => {
setUnlimitedQuota();
}}>{unlimited_quota ? '取消无限额度' : '设置为无限额度'}</Button>
<Form.Field>
<Form.Input
label='过期时间'
@@ -133,22 +118,40 @@ const EditToken = () => {
type='datetime-local'
/>
</Form.Field>
<div style={{ lineHeight: '40px' }}>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}>永不过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}>一个月后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}>一天后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}>一小时后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}>一分钟后过期</Button>
</div>
<Message>注意令牌的额度仅用于限制令牌本身的最大额度使用量实际的使用受到账户的剩余额度限制</Message>
<Form.Field>
<Form.Input
label={`额度${renderQuotaWithPrompt(remain_quota)}`}
name='remain_quota'
placeholder={'请输入额度'}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}>永不过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}>一个月后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}>一天后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}>一小时后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}>一分钟后过期</Button>
<Button onClick={submit}>提交</Button>
setUnlimitedQuota();
}}>{unlimited_quota ? '取消无限额度' : '设置为无限额度'}</Button>
<Button positive onClick={submit}>提交</Button>
</Form>
</Segment>
</>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Segment, Statistic } from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../../helpers';
import { renderQuota } from '../../helpers/render';
const TopUp = () => {
const [redemptionCode, setRedemptionCode] = useState('');
@@ -81,7 +82,7 @@ const TopUp = () => {
<Grid.Column>
<Statistic.Group widths='one'>
<Statistic>
<Statistic.Value>{userQuota.toLocaleString()}</Statistic.Value>
<Statistic.Value>{renderQuota(userQuota)}</Statistic.Value>
<Statistic.Label>剩余额度</Statistic.Label>
</Statistic>
</Statistic.Group>

View File

@@ -65,7 +65,7 @@ const AddUser = () => {
required
/>
</Form.Field>
<Button type={'submit'} onClick={submit}>
<Button positive type={'submit'} onClick={submit}>
提交
</Button>
</Form>

View File

@@ -15,12 +15,26 @@ const EditUser = () => {
wechat_id: '',
email: '',
quota: 0,
group: 'default'
});
const { username, display_name, password, github_id, wechat_id, email, quota } =
const [groupOptions, setGroupOptions] = useState([]);
const { username, display_name, password, github_id, wechat_id, email, quota, group } =
inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({
key: group,
text: group,
value: group,
})));
} catch (error) {
showError(error.message);
}
};
const loadUser = async () => {
let res = undefined;
@@ -40,6 +54,9 @@ const EditUser = () => {
};
useEffect(() => {
loadUser().then();
if (userId) {
fetchGroups().then();
}
}, []);
const submit = async () => {
@@ -98,7 +115,23 @@ const EditUser = () => {
/>
</Form.Field>
{
userId && (
userId && <>
<Form.Field>
<Form.Dropdown
label='分组'
placeholder={'请选择分组'}
name='group'
fluid
search
selection
allowAdditions
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
onChange={handleInputChange}
value={inputs.group}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
<Form.Field>
<Form.Input
label='剩余额度'
@@ -110,7 +143,7 @@ const EditUser = () => {
autoComplete='new-password'
/>
</Form.Field>
)
</>
}
<Form.Field>
<Form.Input
@@ -142,7 +175,7 @@ const EditUser = () => {
readOnly
/>
</Form.Field>
<Button onClick={submit}>提交</Button>
<Button positive onClick={submit}>提交</Button>
</Form>
</Segment>
</>