Compare commits

...

62 Commits

Author SHA1 Message Date
JustSong
d17bdc40a7 fix: use transaction to protect redeem process 2023-07-07 21:26:45 +08:00
JustSong
280df27705 docs: update README 2023-07-05 18:43:11 +08:00
JustSong
991f5bf4ee fix: fix http2: invalid Connection request header: [\"upgrade\"]" 2023-07-04 21:24:49 +08:00
JustSong
701aaba191 feat: able to approximate token (close #207) 2023-07-04 19:54:13 +08:00
mrhaoji
3bab5b48bf chore: remove email in UsersTable to make room for other fields (#246)
* chore: remove email in UsersTable to make room for others

* fix: fix not working properly

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-07-04 18:57:44 +08:00
JustSong
f3bccee3b5 chore: update channel add & edit page 2023-07-04 18:40:36 +08:00
mrhaoji
d84b0b0f5d chore: add model parameter to the time_test script (#245)
default value is gpt-3.5-turbo
2023-07-04 18:13:59 +08:00
Cross Fire
d383302e8a feat: support balance query for CloseAI (#240)
* 增加CloseAI余额查询功能

* 去除debug代码

* fix: bug fix

* docs: update README

---------

Co-authored-by: sudongyang <sudongyang@xiaomi.com>
Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-07-03 20:43:42 +08:00
JustSong
04f40def2f docs: update README 2023-07-03 19:51:12 +08:00
JustSong
c48b7bc0f5 docs: update README 2023-07-03 19:50:17 +08:00
quzard
b09daf5ec1 chore: update countTokenMessages (#238) 2023-07-03 09:42:34 +08:00
JustSong
c90c0ecef4 chore: set the default token quota to 1$ 2023-07-02 16:45:42 +08:00
JustSong
1ab5fb7d2d docs: update README 2023-07-02 16:35:40 +08:00
JustSong
f769711c19 docs: update README 2023-07-02 16:25:14 +08:00
JustSong
edc5156693 chore: ignore FRONTEND_BASE_URL on master node 2023-07-02 16:07:12 +08:00
JustSong
9ec6506c32 fix: fix refresh not working properly (close #229) 2023-07-02 15:55:49 +08:00
JustSong
f387cc5ead docs: update README 2023-07-02 14:57:10 +08:00
JustSong
569b68c43b docs: update README 2023-07-02 14:53:50 +08:00
JustSong
f0c40a6cd0 fix: fix cannot enable token if set to unlimited time (close #230) 2023-07-02 14:47:06 +08:00
JustSong
0cea9e6a6f fix: fix channel search is not working with MySQL 2023-06-29 21:46:13 +08:00
Alone88
b1b3651e84 feat: support channel type AIGC2D (#220)
* feat: add AIGC2D Channel

* chore: remove console logging & update balance rendering

---------

Co-authored-by: Alone88 <im@aloen88.cn>
Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-29 21:34:17 +08:00
JustSong
8f6bd51f58 docs: update README 2023-06-29 21:22:23 +08:00
JustSong
bddbf57104 fix: fix AutomaticDisableChannelEnabled option not ignored (close #217) 2023-06-29 15:54:12 +08:00
JustSong
9a16b0f9e5 docs: update README 2023-06-29 11:39:43 +08:00
JustSong
3530309a31 docs: update issue templates 2023-06-29 11:33:51 +08:00
JustSong
733ebc067b docs: update issue templates 2023-06-29 11:33:02 +08:00
JustSong
6a8567ac14 docs: update issue templates 2023-06-29 11:32:36 +08:00
JustSong
aabc546691 fix: fix the wrong message when channel is deleted 2023-06-29 11:27:34 +08:00
JustSong
1c82b06f35 docs: update README 2023-06-29 10:59:41 +08:00
JustSong
9e4109672a docs: update README 2023-06-28 13:38:09 +08:00
JustSong
64c35334e6 docs: update README 2023-06-28 13:19:01 +08:00
Archer
0ce572b405 docs: update README (#210)
* deploy docs

* docs: update README

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-28 13:01:29 +08:00
mrhaoji
a326ac4b28 chore: more hints in model mapping textarea (#205)
* chore: more hints in model mapping textarea

* fix: fix variable not defined

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-28 12:56:01 +08:00
JustSong
05b0e77839 docs: update README 2023-06-27 23:36:12 +08:00
JustSong
51f19470bc fix: fix wrong env var name 2023-06-27 23:34:23 +08:00
JustSong
737672fb0b fix: update cached user quota after post-consuming (close #204) 2023-06-27 19:22:58 +08:00
JustSong
0941e294bf feat: support model remap now 2023-06-27 13:42:45 +08:00
JustSong
431d505f79 refactor: do not use redis to store session 2023-06-26 16:10:59 +08:00
mrhaoji
f0dc7f3f06 fix: InitChannelCache does not filter disabled channels (#201)
* chore: Show the HTTP status code in the test_time script to determine the success or failure of the request.

* fix: InitChannelCache does not filter disabled channels

* chore: do not hardcode

---------

Co-authored-by: JustSong <songquanpeng@foxmail.com>
2023-06-25 23:14:15 +08:00
mrhaoji
99fed1f850 chore: show the HTTP status code in the test_time script to determine the success or failure of the request (#200) 2023-06-25 22:58:16 +08:00
JustSong
4dc5388a80 chore: do not show completion ratio anymore 2023-06-25 20:29:42 +08:00
JustSong
f81f4c60b2 docs: update README 2023-06-25 15:14:52 +08:00
JustSong
c613d8b6b2 docs: update README 2023-06-25 15:14:09 +08:00
JustSong
7adac1c09c chore: update default ratio for text-embedding-ada-002 2023-06-25 12:07:42 +08:00
mrhaoji
6f05128368 chore: show equivalent amount next to remaining quota in the user editing page (#198) 2023-06-25 11:54:05 +08:00
JustSong
9b178a28a3 feat: support /v1/edits now (close #196) 2023-06-25 11:46:23 +08:00
JustSong
4a6a7f4635 chore: update the number that representing the unlimited quota 2023-06-25 10:52:46 +08:00
JustSong
6b1a24d650 fix: check if token is nil before using it 2023-06-25 10:40:54 +08:00
JustSong
94ba3dd024 chore: billing api now will return a large number if unlimited quota is set 2023-06-25 10:39:22 +08:00
JustSong
f6eb4e5628 perf: validate the request first before send to OpenAI's server 2023-06-25 10:25:33 +08:00
JustSong
57bd907f83 fix: do not record if used quota is zero 2023-06-25 09:59:58 +08:00
JustSong
dd8e8d5ee8 fix: do not charge the user if the amount of tokens used was zero 2023-06-25 09:56:03 +08:00
JustSong
1ca1aa0cdc fix: fix usage is not correct 2023-06-25 09:36:39 +08:00
quzard
f2ba0c0300 fix: fix log sorting (#195) 2023-06-24 21:34:20 +08:00
JustSong
f5c1fcd3c3 fix: do not reuse state variable directly 2023-06-24 19:45:18 +08:00
JustSong
5fdf670a19 fix: reset page number after query 2023-06-24 19:32:46 +08:00
JustSong
3ce982d8ee feat: able to query token with admin user 2023-06-24 19:13:33 +08:00
JustSong
a515f9284e chore: adjust table width 2023-06-24 15:42:16 +08:00
JustSong
cccf5e4a07 feat: able to query logs now (close #144) 2023-06-24 15:28:11 +08:00
JustSong
b0bfb9c9a1 chore: update en.json 2023-06-24 14:44:34 +08:00
JustSong
3aff61a973 chore: update completion ratio for GPT-3 2023-06-24 14:19:50 +08:00
JustSong
0fd1ff4d9e chore: update translations 2023-06-24 12:26:38 +08:00
39 changed files with 837 additions and 298 deletions

View File

@@ -8,11 +8,13 @@ assignees: ''
--- ---
**例行检查** **例行检查**
[//]: # (方框内删除已有的空格,填 x 号)
+ [ ] 我已确认目前没有类似 issue + [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本 + [ ] 我已确认我已升级到最新版本
+ [ ] 我已完整查看过项目 README尤其是常见问题部分 + [ ] 我已完整查看过项目 README尤其是常见问题部分
+ [ ] 我理解并愿意跟进此 issue协助测试和提供反馈 + [ ] 我理解并愿意跟进此 issue协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭 + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
**问题描述** **问题描述**

View File

@@ -6,6 +6,3 @@ contact_links:
- name: 赞赏支持 - name: 赞赏支持
url: https://iamazing.cn/page/reward url: https://iamazing.cn/page/reward
about: 请作者喝杯咖啡,以激励作者持续开发 about: 请作者喝杯咖啡,以激励作者持续开发
- name: 付费部署或定制功能
url: https://openai.justsong.cn/
about: 加群后联系群主

View File

@@ -8,10 +8,13 @@ assignees: ''
--- ---
**例行检查** **例行检查**
[//]: # (方框内删除已有的空格,填 x 号)
+ [ ] 我已确认目前没有类似 issue + [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本 + [ ] 我已确认我已升级到最新版本
+ [ ] 我已完整查看过项目 README已确定现有版本无法满足需求
+ [ ] 我理解并愿意跟进此 issue协助测试和提供反馈 + [ ] 我理解并愿意跟进此 issue协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭 + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
**功能描述** **功能描述**

View File

@@ -10,7 +10,7 @@
# One API # One API
_✨ The all-in-one OpenAI interface, integrates various API access methods, ready to use ✨_ _✨ An OpenAI key management & redistribution system, easy to deploy & use ✨_
</div> </div>
@@ -57,17 +57,14 @@ _✨ The all-in-one OpenAI interface, integrates various API access methods, rea
> **Note**: The latest image pulled from Docker may be an `alpha` release. Specify the version manually if you require stability. > **Note**: The latest image pulled from Docker may be an `alpha` release. Specify the version manually if you require stability.
## Features ## Features
1. Supports multiple API access channels. Welcome PRs or issue submissions for additional channels: 1. Supports multiple API access channels:
+ [x] Official OpenAI channel (support proxy configuration) + [x] Official OpenAI channel (support proxy configuration)
+ [x] **Azure OpenAI API** + [x] **Azure OpenAI API**
+ [x] [API Distribute](https://api.gptjk.top/register?aff=QGxj)
+ [x] [OpenAI-SB](https://openai-sb.com) + [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [API2D](https://api2d.com/r/197971) + [x] [API2D](https://api2d.com/r/197971)
+ [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf) + [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf)
+ [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (invitation code: `OneAPI`) + [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 + [x] Custom channel: Various third-party proxy services not included in the list
2. Supports access to multiple channels through **load balancing**. 2. Supports access to multiple channels through **load balancing**.
3. Supports **stream mode** that enables typewriter-like effect through stream transmission. 3. Supports **stream mode** that enables typewriter-like effect through stream transmission.
@@ -174,6 +171,15 @@ Refer to [#175](https://github.com/songquanpeng/one-api/issues/175) for detailed
If you encounter a blank page after deployment, refer to [#97](https://github.com/songquanpeng/one-api/issues/97) for possible solutions. 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 ### Deployment on Third-Party Platforms
<details>
<summary><strong>Deploy on Sealos</strong></summary>
<div>
Please refer to [this tutorial](https://github.com/c121914yu/FastGPT/blob/main/docs/deploy/one-api/sealos.md).
</div>
</details>
<details> <details>
<summary><strong>Deployment on Zeabur</strong></summary> <summary><strong>Deployment on Zeabur</strong></summary>
<div> <div>
@@ -240,7 +246,7 @@ If the channel ID is not provided, load balancing will be used to distribute the
+ Example: `CHANNEL_UPDATE_FREQUENCY=1440` + 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. 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` + 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. 9. `POLLING_INTERVAL`: The time interval (in seconds) between requests when updating channel balances and testing channel availability. Default is no interval.
+ Example: `POLLING_INTERVAL=5` + Example: `POLLING_INTERVAL=5`
### Command Line Parameters ### Command Line Parameters
@@ -279,6 +285,10 @@ If the channel ID is not provided, load balancing will be used to distribute the
## Note ## 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 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. This project is released under the MIT license. Based on this, attribution and a link to this project must be included at the bottom of the page.
The same applies to derivative projects based on this project.
If you do not wish to include attribution, prior authorization must be obtained.
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. 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.

View File

@@ -51,27 +51,27 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
<a href="https://iamazing.cn/page/reward">赞赏支持</a> <a href="https://iamazing.cn/page/reward">赞赏支持</a>
</p> </p>
> **Note**:本项目为开源项目,使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
> **Note**:使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本。 > **Note**:使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本。
> **Warning**:从 `v0.3` 版本升级到 `v0.4` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.3-v0.4.sql)。 > **Warning**:从 `v0.3` 版本升级到 `v0.4` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.3-v0.4.sql)。
## 功能 ## 功能
1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道 1. 支持多种 API 访问渠道:
+ [x] OpenAI 官方通道(支持配置代理 + [x] OpenAI 官方通道(支持配置镜像
+ [x] **Azure OpenAI API** + [x] **Azure OpenAI API**
+ [x] [API Distribute](https://api.gptjk.top/register?aff=QGxj)
+ [x] [OpenAI-SB](https://openai-sb.com) + [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [API2D](https://api2d.com/r/197971) + [x] [API2D](https://api2d.com/r/197971)
+ [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf) + [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf)
+ [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (邀请码:`OneAPI` + [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] [CloseAI](https://console.closeai-asia.com/r/2412)
+ [x] [AI.LS](https://ai.ls)
+ [x] [OpenAI Max](https://openaimax.com)
+ [x] 自定义渠道:例如各种未收录的第三方代理服务 + [x] 自定义渠道:例如各种未收录的第三方代理服务
2. 支持通过**负载均衡**的方式访问多个渠道。 2. 支持通过**负载均衡**的方式访问多个渠道。
3. 支持 **stream 模式**,可以通过流式传输实现打字机效果。 3. 支持 **stream 模式**,可以通过流式传输实现打字机效果。
4. 支持**多机部署**[详见此处](#多机部署)。 4. 支持**多机部署**[详见此处](#多机部署)。
5. 支持**令牌管理**,设置令牌的过期时间和使用次数 5. 支持**令牌管理**,设置令牌的过期时间和额度
6. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。 6. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。
7. 支持**通道管理**,批量创建通道。 7. 支持**通道管理**,批量创建通道。
8. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。 8. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。
@@ -80,21 +80,26 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
11. 支持**用户邀请奖励**。 11. 支持**用户邀请奖励**。
12. 支持以美元为单位显示额度。 12. 支持以美元为单位显示额度。
13. 支持发布公告,设置充值链接,设置新用户初始额度。 13. 支持发布公告,设置充值链接,设置新用户初始额度。
14. 支持丰富的**自定义**设置, 14. 支持模型映射,重定向用户的请求模型。
15. 支持丰富的**自定义**设置,
1. 支持自定义系统名称logo 以及页脚。 1. 支持自定义系统名称logo 以及页脚。
2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。 2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。
15. 支持通过系统访问令牌访问管理 API。 16. 支持通过系统访问令牌访问管理 API。
16. 支持 Cloudflare Turnstile 用户校验。 17. 支持 Cloudflare Turnstile 用户校验。
17. 支持用户管理,支持**多种用户登录注册方式** 18. 支持用户管理,支持**多种用户登录注册方式**
+ 邮箱登录注册以及通过邮箱进行密码重置。 + 邮箱登录注册以及通过邮箱进行密码重置。
+ [GitHub 开放授权](https://github.com/settings/applications/new)。 + [GitHub 开放授权](https://github.com/settings/applications/new)。
+ 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。
18. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。 19. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。
## 部署 ## 部署
### 基于 Docker 进行部署 ### 基于 Docker 进行部署
部署命令:`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 --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api`
如果上面的镜像无法拉取,可以尝试使用 GitHub 的 Docker 镜像,将上面的 `justsong/one-api` 替换为 `ghcr.io/songquanpeng/one-api` 即可。
如果你的并发量较大,推荐设置 `SQL_DSN`,详见下面[环境变量](#环境变量)一节。
更新命令:`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR` 更新命令:`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR`
`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。 `-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。
@@ -114,6 +119,7 @@ server{
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_set_header Accept-Encoding gzip; proxy_set_header Accept-Encoding gzip;
proxy_read_timeout 300s; # GPT-4 需要较长的超时时间,请自行调整
} }
} }
``` ```
@@ -159,8 +165,8 @@ sudo service nginx restart
### 多机部署 ### 多机部署
1. 所有服务器 `SESSION_SECRET` 设置一样的值。 1. 所有服务器 `SESSION_SECRET` 设置一样的值。
2. 必须设置 `SQL_DSN`,使用 MySQL 数据库而非 SQLite所有服务器连接同一个数据库。 2. 必须设置 `SQL_DSN`,使用 MySQL 数据库而非 SQLite所有服务器连接同一个数据库。
3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`。 3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`,不设置则默认为主服务器
4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置。 4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置,在使用远程数据库的情况下,推荐设置该项并启用 Redis无论主从
5. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器。 5. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器。
6. 从服务器上**分别**装好 Redis设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟。 6. 从服务器上**分别**装好 Redis设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟。
7. 如果主服务器访问数据库延迟也比较高,则也需要启用 Redis并设置 `SYNC_FREQUENCY`,以定期从数据库同步配置。 7. 如果主服务器访问数据库延迟也比较高,则也需要启用 Redis并设置 `SYNC_FREQUENCY`,以定期从数据库同步配置。
@@ -195,6 +201,17 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope
注意修改端口号、`OPENAI_API_BASE_URL` 和 `OPENAI_API_KEY`。 注意修改端口号、`OPENAI_API_BASE_URL` 和 `OPENAI_API_KEY`。
### 部署到第三方平台 ### 部署到第三方平台
<details>
<summary><strong>部署到 Sealos </strong></summary>
<div>
> Sealos 可视化部署,仅需 1 分钟。
参考这个[教程](https://github.com/c121914yu/FastGPT/blob/main/docs/deploy/one-api/sealos.md)中 1~5 步。
</div>
</details>
<details> <details>
<summary><strong>部署到 Zeabur</strong></summary> <summary><strong>部署到 Zeabur</strong></summary>
<div> <div>
@@ -221,6 +238,8 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope
等到系统启动后,使用 `root` 用户登录系统并做进一步的配置。 等到系统启动后,使用 `root` 用户登录系统并做进一步的配置。
**Note**:如果你不知道某个配置项的含义,可以临时删掉值以看到进一步的提示文字。
## 使用方法 ## 使用方法
在`渠道`页面中添加你的 API Key之后在`令牌`页面中新增访问令牌。 在`渠道`页面中添加你的 API Key之后在`令牌`页面中新增访问令牌。
@@ -251,7 +270,10 @@ graph LR
+ 例子:`SESSION_SECRET=random_string` + 例子:`SESSION_SECRET=random_string`
3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite请使用 MySQL 8.0 版本。 3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite请使用 MySQL 8.0 版本。
+ 例子:`SQL_DSN=root:123456@tcp(localhost:3306)/oneapi` + 例子:`SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`
4. `FRONTEND_BASE_URL`:设置之后将使用指定的前端地址,而非后端地址 + 注意需要提前建立数据库 `oneapi`,无需手动建表,程序将自动建表
+ 如果使用本地数据库:部署命令可添加 `--network="host"` 以使得容器内的程序可以访问到宿主机上的 MySQL。
+ 如果使用云数据库:如果云服务器需要验证身份,需要在连接参数中添加 `?tls=skip-verify`。
4. `FRONTEND_BASE_URL`:设置之后将重定向页面请求到指定的地址,仅限从服务器设置。
+ 例子:`FRONTEND_BASE_URL=https://openai.justsong.cn` + 例子:`FRONTEND_BASE_URL=https://openai.justsong.cn`
5. `SYNC_FREQUENCY`:设置之后将定期与数据库同步配置,单位为秒,未设置则不进行同步。 5. `SYNC_FREQUENCY`:设置之后将定期与数据库同步配置,单位为秒,未设置则不进行同步。
+ 例子:`SYNC_FREQUENCY=60` + 例子:`SYNC_FREQUENCY=60`
@@ -261,7 +283,7 @@ graph LR
+ 例子:`CHANNEL_UPDATE_FREQUENCY=1440` + 例子:`CHANNEL_UPDATE_FREQUENCY=1440`
8. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。 8. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。
+ 例子:`CHANNEL_TEST_FREQUENCY=1440` + 例子:`CHANNEL_TEST_FREQUENCY=1440`
9. `REQUEST_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。 9. `POLLING_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。
+ 例子:`POLLING_INTERVAL=5` + 例子:`POLLING_INTERVAL=5`
### 命令行参数 ### 命令行参数
@@ -298,13 +320,16 @@ https://openai.justsong.cn
5. ChatGPT Next Web 报错:`Failed to fetch` 5. ChatGPT Next Web 报错:`Failed to fetch`
+ 部署的时候不要设置 `BASE_URL`。 + 部署的时候不要设置 `BASE_URL`。
+ 检查你的接口地址和 API Key 有没有填对。 + 检查你的接口地址和 API Key 有没有填对。
6. 报错:`当前分组负载已饱和,请稍后再试`
+ 上游通道 429 了。
## 相关项目 ## 相关项目
[FastGPT](https://github.com/c121914yu/FastGPT): 三分钟搭建 AI 知识库 [FastGPT](https://github.com/c121914yu/FastGPT): 三分钟搭建 AI 知识库
## 注意 ## 注意
本项目为开源项目,请在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
本项目使用 MIT 协议进行开源,请以某种方式保留 One API 的版权信息 本项目使用 MIT 协议进行开源,**在此基础上**,必须在页面底部保留署名以及指向本项目的链接。如果不想保留署名,必须首先获得授权
同样适用于基于本项目的二开项目。
依据 MIT 协议,使用者需自行承担使用本项目的风险与责任,本开源项目开发者与此无关。 依据 MIT 协议,使用者需自行承担使用本项目的风险与责任,本开源项目开发者与此无关。

View File

@@ -1,25 +1,29 @@
#!/bin/bash #!/bin/bash
if [ $# -ne 3 ]; then if [ $# -lt 3 ]; then
echo "Usage: time_test.sh <domain> <key> <count>" echo "Usage: time_test.sh <domain> <key> <count> [<model>]"
exit 1 exit 1
fi fi
domain=$1 domain=$1
key=$2 key=$2
count=$3 count=$3
model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo
total_time=0 total_time=0
times=() times=()
for ((i=1; i<=count; i++)); do for ((i=1; i<=count; i++)); do
result=$(curl -o /dev/null -s -w %{time_total}\\n \ result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \
https://"$domain"/v1/chat/completions \ https://"$domain"/v1/chat/completions \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer $key" \ -H "Authorization: Bearer $key" \
-d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "gpt-3.5-turbo", "stream": false, "max_tokens": 1}') -d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}')
echo "$result" http_code=$(echo "$result" | awk '{print $1}')
total_time=$(bc <<< "$total_time + $result") time=$(echo "$result" | awk '{print $2}')
times+=("$result") echo "HTTP status code: $http_code, Time taken: $time"
total_time=$(bc <<< "$total_time + $time")
times+=("$time")
done done
average_time=$(echo "scale=4; $total_time / $count" | bc) average_time=$(echo "scale=4; $total_time / $count" | bc)

View File

@@ -67,12 +67,13 @@ var ChannelDisableThreshold = 5.0
var AutomaticDisableChannelEnabled = false var AutomaticDisableChannelEnabled = false
var QuotaRemindThreshold = 1000 var QuotaRemindThreshold = 1000
var PreConsumedQuota = 500 var PreConsumedQuota = 500
var ApproximateTokenEnabled = false
var RootUserEmail = "" var RootUserEmail = ""
var IsMasterNode = os.Getenv("NODE_TYPE") != "slave" var IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
var requestInterval, _ = strconv.Atoi(os.Getenv("REQUEST_INTERVAL")) var requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL"))
var RequestInterval = time.Duration(requestInterval) * time.Second var RequestInterval = time.Duration(requestInterval) * time.Second
const ( const (
@@ -148,20 +149,22 @@ const (
ChannelTypeAIProxy = 10 ChannelTypeAIProxy = 10
ChannelTypePaLM = 11 ChannelTypePaLM = 11
ChannelTypeAPI2GPT = 12 ChannelTypeAPI2GPT = 12
ChannelTypeAIGC2D = 13
) )
var ChannelBaseURLs = []string{ var ChannelBaseURLs = []string{
"", // 0 "", // 0
"https://api.openai.com", // 1 "https://api.openai.com", // 1
"https://oa.api2d.net", // 2 "https://oa.api2d.net", // 2
"", // 3 "", // 3
"https://api.openai-proxy.org", // 4 "https://api.closeai-proxy.xyz", // 4
"https://api.openai-sb.com", // 5 "https://api.openai-sb.com", // 5
"https://api.openaimax.com", // 6 "https://api.openaimax.com", // 6
"https://api.ohmygpt.com", // 7 "https://api.ohmygpt.com", // 7
"", // 8 "", // 8
"https://api.caipacity.com", // 9 "https://api.caipacity.com", // 9
"https://api.aiproxy.io", // 10 "https://api.aiproxy.io", // 10
"", // 11 "", // 11
"https://api.api2gpt.com", // 12 "https://api.api2gpt.com", // 12
"https://api.aigc2d.com", // 13
} }

View File

@@ -31,7 +31,7 @@ var ModelRatio = map[string]float64{
"curie": 10, "curie": 10,
"babbage": 10, "babbage": 10,
"ada": 10, "ada": 10,
"text-embedding-ada-002": 0.2, "text-embedding-ada-002": 0.05,
"text-search-ada-doc-001": 10, "text-search-ada-doc-001": 10,
"text-moderation-stable": 0.1, "text-moderation-stable": 0.1,
"text-moderation-latest": 0.1, "text-moderation-latest": 0.1,

View File

@@ -32,6 +32,9 @@ func GetSubscription(c *gin.Context) {
if common.DisplayInCurrencyEnabled { if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit amount /= common.QuotaPerUnit
} }
if token != nil && token.UnlimitedQuota {
amount = 100000000
}
subscription := OpenAISubscriptionResponse{ subscription := OpenAISubscriptionResponse{
Object: "billing_subscription", Object: "billing_subscription",
HasPaymentMethod: true, HasPaymentMethod: true,
@@ -71,7 +74,7 @@ func GetUsage(c *gin.Context) {
} }
usage := OpenAIUsageResponse{ usage := OpenAIUsageResponse{
Object: "list", Object: "list",
TotalUsage: amount, TotalUsage: amount * 100,
} }
c.JSON(200, usage) c.JSON(200, usage)
return return

View File

@@ -32,6 +32,13 @@ type OpenAIUsageDailyCost struct {
} }
} }
type OpenAICreditGrants struct {
Object string `json:"object"`
TotalGranted float64 `json:"total_granted"`
TotalUsed float64 `json:"total_used"`
TotalAvailable float64 `json:"total_available"`
}
type OpenAIUsageResponse struct { type OpenAIUsageResponse struct {
Object string `json:"object"` Object string `json:"object"`
//DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"` //DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"`
@@ -61,6 +68,14 @@ type API2GPTUsageResponse struct {
TotalRemaining float64 `json:"total_remaining"` TotalRemaining float64 `json:"total_remaining"`
} }
type APGC2DGPTUsageResponse struct {
//Grants interface{} `json:"grants"`
Object string `json:"object"`
TotalAvailable float64 `json:"total_available"`
TotalGranted float64 `json:"total_granted"`
TotalUsed float64 `json:"total_used"`
}
// GetAuthHeader get auth header // GetAuthHeader get auth header
func GetAuthHeader(token string) http.Header { func GetAuthHeader(token string) http.Header {
h := http.Header{} h := http.Header{}
@@ -92,6 +107,22 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
return body, nil return body, nil
} }
func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) {
url := fmt.Sprintf("%s/dashboard/billing/credit_grants", channel.BaseURL)
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := OpenAICreditGrants{}
err = json.Unmarshal(body, &response)
if err != nil {
return 0, err
}
channel.UpdateBalance(response.TotalAvailable)
return response.TotalAvailable, nil
}
func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) { func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) {
url := fmt.Sprintf("https://api.openai-sb.com/sb-api/user/status?api_key=%s", channel.Key) 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)) body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
@@ -150,8 +181,26 @@ func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) {
return response.TotalRemaining, nil return response.TotalRemaining, nil
} }
func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) {
url := "https://api.aigc2d.com/dashboard/billing/credit_grants"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := APGC2DGPTUsageResponse{}
err = json.Unmarshal(body, &response)
if err != nil {
return 0, err
}
channel.UpdateBalance(response.TotalAvailable)
return response.TotalAvailable, nil
}
func updateChannelBalance(channel *model.Channel) (float64, error) { func updateChannelBalance(channel *model.Channel) (float64, error) {
baseURL := common.ChannelBaseURLs[channel.Type] baseURL := common.ChannelBaseURLs[channel.Type]
if channel.BaseURL == "" {
channel.BaseURL = baseURL
}
switch channel.Type { switch channel.Type {
case common.ChannelTypeOpenAI: case common.ChannelTypeOpenAI:
if channel.BaseURL != "" { if channel.BaseURL != "" {
@@ -161,12 +210,16 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
return 0, errors.New("尚未实现") return 0, errors.New("尚未实现")
case common.ChannelTypeCustom: case common.ChannelTypeCustom:
baseURL = channel.BaseURL baseURL = channel.BaseURL
case common.ChannelTypeCloseAI:
return updateChannelCloseAIBalance(channel)
case common.ChannelTypeOpenAISB: case common.ChannelTypeOpenAISB:
return updateChannelOpenAISBBalance(channel) return updateChannelOpenAISBBalance(channel)
case common.ChannelTypeAIProxy: case common.ChannelTypeAIProxy:
return updateChannelAIProxyBalance(channel) return updateChannelAIProxyBalance(channel)
case common.ChannelTypeAPI2GPT: case common.ChannelTypeAPI2GPT:
return updateChannelAPI2GPTBalance(channel) return updateChannelAPI2GPTBalance(channel)
case common.ChannelTypeAIGC2D:
return updateChannelAIGC2DBalance(channel)
default: default:
return 0, errors.New("尚未实现") return 0, errors.New("尚未实现")
} }

View File

@@ -13,7 +13,12 @@ func GetAllLogs(c *gin.Context) {
p = 0 p = 0
} }
logType, _ := strconv.Atoi(c.Query("type")) logType, _ := strconv.Atoi(c.Query("type"))
logs, err := model.GetAllLogs(logType, p*common.ItemsPerPage, common.ItemsPerPage) startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
username := c.Query("username")
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
logs, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, p*common.ItemsPerPage, common.ItemsPerPage)
if err != nil { if err != nil {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
@@ -35,7 +40,11 @@ func GetUserLogs(c *gin.Context) {
} }
userId := c.GetInt("id") userId := c.GetInt("id")
logType, _ := strconv.Atoi(c.Query("type")) logType, _ := strconv.Atoi(c.Query("type"))
logs, err := model.GetUserLogs(userId, logType, p*common.ItemsPerPage, common.ItemsPerPage) startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
logs, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, p*common.ItemsPerPage, common.ItemsPerPage)
if err != nil { if err != nil {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
@@ -84,3 +93,41 @@ func SearchUserLogs(c *gin.Context) {
"data": logs, "data": logs,
}) })
} }
func GetLogsStat(c *gin.Context) {
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
tokenName := c.Query("token_name")
username := c.Query("username")
modelName := c.Query("model_name")
quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName)
//tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "")
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"quota": quotaNum,
//"token": tokenNum,
},
})
}
func GetLogsSelfStat(c *gin.Context) {
username := c.GetString("username")
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName)
//tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"quota": quotaNum,
//"token": tokenNum,
},
})
}

View File

@@ -224,6 +224,24 @@ func init() {
Root: "text-moderation-stable", Root: "text-moderation-stable",
Parent: nil, Parent: nil,
}, },
{
Id: "text-davinci-edit-001",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "text-davinci-edit-001",
Parent: nil,
},
{
Id: "code-davinci-edit-001",
Object: "model",
Created: 1677649963,
OwnedBy: "openai",
Permission: permission,
Root: "code-davinci-edit-001",
Parent: nil,
},
} }
openAIModelsMap = make(map[string]OpenAIModels) openAIModelsMap = make(map[string]OpenAIModels)
for _, model := range openAIModels { for _, model := range openAIModels {

View File

@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"io" "io"
@@ -26,9 +27,46 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
return errorWrapper(err, "bind_request_body_failed", http.StatusBadRequest) return errorWrapper(err, "bind_request_body_failed", http.StatusBadRequest)
} }
} }
if relayMode == RelayModeModeration && textRequest.Model == "" { if relayMode == RelayModeModerations && textRequest.Model == "" {
textRequest.Model = "text-moderation-latest" textRequest.Model = "text-moderation-latest"
} }
// request validation
if textRequest.Model == "" {
return errorWrapper(errors.New("model is required"), "required_field_missing", http.StatusBadRequest)
}
switch relayMode {
case RelayModeCompletions:
if textRequest.Prompt == "" {
return errorWrapper(errors.New("field prompt is required"), "required_field_missing", http.StatusBadRequest)
}
case RelayModeChatCompletions:
if textRequest.Messages == nil || len(textRequest.Messages) == 0 {
return errorWrapper(errors.New("field messages is required"), "required_field_missing", http.StatusBadRequest)
}
case RelayModeEmbeddings:
case RelayModeModerations:
if textRequest.Input == "" {
return errorWrapper(errors.New("field input is required"), "required_field_missing", http.StatusBadRequest)
}
case RelayModeEdits:
if textRequest.Instruction == "" {
return errorWrapper(errors.New("field instruction is required"), "required_field_missing", http.StatusBadRequest)
}
}
// map model name
modelMapping := c.GetString("model_mapping")
isModelMapped := false
if modelMapping != "" {
modelMap := make(map[string]string)
err := json.Unmarshal([]byte(modelMapping), &modelMap)
if err != nil {
return errorWrapper(err, "unmarshal_model_mapping_failed", http.StatusInternalServerError)
}
if modelMap[textRequest.Model] != "" {
textRequest.Model = modelMap[textRequest.Model]
isModelMapped = true
}
}
baseURL := common.ChannelBaseURLs[channelType] baseURL := common.ChannelBaseURLs[channelType]
requestURL := c.Request.URL.String() requestURL := c.Request.URL.String()
if c.GetString("base_url") != "" { if c.GetString("base_url") != "" {
@@ -58,12 +96,13 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
return err return err
} }
var promptTokens int var promptTokens int
var completionTokens int
switch relayMode { switch relayMode {
case RelayModeChatCompletions: case RelayModeChatCompletions:
promptTokens = countTokenMessages(textRequest.Messages, textRequest.Model) promptTokens = countTokenMessages(textRequest.Messages, textRequest.Model)
case RelayModeCompletions: case RelayModeCompletions:
promptTokens = countTokenInput(textRequest.Prompt, textRequest.Model) promptTokens = countTokenInput(textRequest.Prompt, textRequest.Model)
case RelayModeModeration: case RelayModeModerations:
promptTokens = countTokenInput(textRequest.Input, textRequest.Model) promptTokens = countTokenInput(textRequest.Input, textRequest.Model)
} }
preConsumedTokens := common.PreConsumedQuota preConsumedTokens := common.PreConsumedQuota
@@ -89,7 +128,17 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusForbidden) return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusForbidden)
} }
} }
req, err := http.NewRequest(c.Request.Method, fullRequestURL, c.Request.Body) var requestBody io.Reader
if isModelMapped {
jsonStr, err := json.Marshal(textRequest)
if err != nil {
return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError)
}
requestBody = bytes.NewBuffer(jsonStr)
} else {
requestBody = c.Request.Body
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
if err != nil { if err != nil {
return errorWrapper(err, "new_request_failed", http.StatusInternalServerError) return errorWrapper(err, "new_request_failed", http.StatusInternalServerError)
} }
@@ -102,7 +151,7 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
} }
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept")) req.Header.Set("Accept", c.Request.Header.Get("Accept"))
req.Header.Set("Connection", c.Request.Header.Get("Connection")) //req.Header.Set("Connection", c.Request.Header.Get("Connection"))
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
@@ -123,30 +172,47 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
defer func() { defer func() {
if consumeQuota { if consumeQuota {
quota := 0 quota := 0
completionRatio := 1.34 // default for gpt-3 completionRatio := 1.0
if strings.HasPrefix(textRequest.Model, "gpt-3.5") {
completionRatio = 1.333333
}
if strings.HasPrefix(textRequest.Model, "gpt-4") { if strings.HasPrefix(textRequest.Model, "gpt-4") {
completionRatio = 2 completionRatio = 2
} }
if isStream { if isStream {
responseTokens := countTokenText(streamResponseText, textRequest.Model) completionTokens = countTokenText(streamResponseText, textRequest.Model)
quota = promptTokens + int(float64(responseTokens)*completionRatio)
} else { } else {
quota = textResponse.Usage.PromptTokens + int(float64(textResponse.Usage.CompletionTokens)*completionRatio) promptTokens = textResponse.Usage.PromptTokens
completionTokens = textResponse.Usage.CompletionTokens
} }
quota = promptTokens + int(float64(completionTokens)*completionRatio)
quota = int(float64(quota) * ratio) quota = int(float64(quota) * ratio)
if ratio != 0 && quota <= 0 { if ratio != 0 && quota <= 0 {
quota = 1 quota = 1
} }
totalTokens := promptTokens + completionTokens
if totalTokens == 0 {
// in this case, must be some error happened
// we cannot just return, because we may have to return the pre-consumed quota
quota = 0
}
quotaDelta := quota - preConsumedQuota quotaDelta := quota - preConsumedQuota
err := model.PostConsumeTokenQuota(tokenId, quotaDelta) err := model.PostConsumeTokenQuota(tokenId, quotaDelta)
if err != nil { if err != nil {
common.SysError("error consuming token remain quota: " + err.Error()) common.SysError("error consuming token remain quota: " + err.Error())
} }
tokenName := c.GetString("token_name") err = model.CacheUpdateUserQuota(userId)
model.RecordLog(userId, model.LogTypeConsume, fmt.Sprintf("通过令牌「%s」使用模型 %s 消耗 %s模型倍率 %.2f,分组倍率 %.2f", tokenName, textRequest.Model, common.LogQuota(quota), modelRatio, groupRatio)) if err != nil {
model.UpdateUserUsedQuotaAndRequestCount(userId, quota) common.SysError("error update user quota cache: " + err.Error())
channelId := c.GetInt("channel_id") }
model.UpdateChannelUsedQuota(channelId, quota) if quota != 0 {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio)
model.RecordConsumeLog(userId, promptTokens, completionTokens, textRequest.Model, tokenName, quota, logContent)
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
channelId := c.GetInt("channel_id")
model.UpdateChannelUsedQuota(channelId, quota)
}
} }
}() }()

View File

@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"github.com/pkoukk/tiktoken-go" "github.com/pkoukk/tiktoken-go"
"one-api/common" "one-api/common"
"strings"
) )
var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} var tokenEncoderMap = map[string]*tiktoken.Tiktoken{}
@@ -25,6 +24,13 @@ func getTokenEncoder(model string) *tiktoken.Tiktoken {
return tokenEncoder return tokenEncoder
} }
func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int {
if common.ApproximateTokenEnabled {
return int(float64(len(text)) * 0.38)
}
return len(tokenEncoder.Encode(text, nil, nil))
}
func countTokenMessages(messages []Message, model string) int { func countTokenMessages(messages []Message, model string) int {
tokenEncoder := getTokenEncoder(model) tokenEncoder := getTokenEncoder(model)
// Reference: // Reference:
@@ -34,12 +40,9 @@ func countTokenMessages(messages []Message, model string) int {
// Every message follows <|start|>{role/name}\n{content}<|end|>\n // Every message follows <|start|>{role/name}\n{content}<|end|>\n
var tokensPerMessage int var tokensPerMessage int
var tokensPerName int var tokensPerName int
if strings.HasPrefix(model, "gpt-3.5") { if model == "gpt-3.5-turbo-0301" {
tokensPerMessage = 4 tokensPerMessage = 4
tokensPerName = -1 // If there's a name, the role is omitted tokensPerName = -1 // If there's a name, the role is omitted
} else if strings.HasPrefix(model, "gpt-4") {
tokensPerMessage = 3
tokensPerName = 1
} else { } else {
tokensPerMessage = 3 tokensPerMessage = 3
tokensPerName = 1 tokensPerName = 1
@@ -47,11 +50,11 @@ func countTokenMessages(messages []Message, model string) int {
tokenNum := 0 tokenNum := 0
for _, message := range messages { for _, message := range messages {
tokenNum += tokensPerMessage tokenNum += tokensPerMessage
tokenNum += len(tokenEncoder.Encode(message.Content, nil, nil)) tokenNum += getTokenNum(tokenEncoder, message.Content)
tokenNum += len(tokenEncoder.Encode(message.Role, nil, nil)) tokenNum += getTokenNum(tokenEncoder, message.Role)
if message.Name != nil { if message.Name != nil {
tokenNum += tokensPerName tokenNum += tokensPerName
tokenNum += len(tokenEncoder.Encode(*message.Name, nil, nil)) tokenNum += getTokenNum(tokenEncoder, *message.Name)
} }
} }
tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|> tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|>
@@ -74,8 +77,7 @@ func countTokenInput(input any, model string) int {
func countTokenText(text string, model string) int { func countTokenText(text string, model string) int {
tokenEncoder := getTokenEncoder(model) tokenEncoder := getTokenEncoder(model)
token := tokenEncoder.Encode(text, nil, nil) return getTokenNum(tokenEncoder, text)
return len(token)
} }
func errorWrapper(err error, code string, statusCode int) *OpenAIErrorWithStatusCode { func errorWrapper(err error, code string, statusCode int) *OpenAIErrorWithStatusCode {

View File

@@ -19,22 +19,24 @@ const (
RelayModeChatCompletions RelayModeChatCompletions
RelayModeCompletions RelayModeCompletions
RelayModeEmbeddings RelayModeEmbeddings
RelayModeModeration RelayModeModerations
RelayModeImagesGenerations RelayModeImagesGenerations
RelayModeEdits
) )
// https://platform.openai.com/docs/api-reference/chat // https://platform.openai.com/docs/api-reference/chat
type GeneralOpenAIRequest struct { type GeneralOpenAIRequest struct {
Model string `json:"model"` Model string `json:"model,omitempty"`
Messages []Message `json:"messages"` Messages []Message `json:"messages,omitempty"`
Prompt any `json:"prompt"` Prompt any `json:"prompt,omitempty"`
Stream bool `json:"stream"` Stream bool `json:"stream,omitempty"`
MaxTokens int `json:"max_tokens"` MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p"` TopP float64 `json:"top_p,omitempty"`
N int `json:"n"` N int `json:"n,omitempty"`
Input any `json:"input"` Input any `json:"input,omitempty"`
Instruction string `json:"instruction,omitempty"`
} }
type ChatRequest struct { type ChatRequest struct {
@@ -99,9 +101,11 @@ func Relay(c *gin.Context) {
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/embeddings") { } else if strings.HasPrefix(c.Request.URL.Path, "/v1/embeddings") {
relayMode = RelayModeEmbeddings relayMode = RelayModeEmbeddings
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") { } else if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") {
relayMode = RelayModeModeration relayMode = RelayModeModerations
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
relayMode = RelayModeImagesGenerations relayMode = RelayModeImagesGenerations
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/edits") {
relayMode = RelayModeEdits
} }
var err *OpenAIErrorWithStatusCode var err *OpenAIErrorWithStatusCode
switch relayMode { switch relayMode {

View File

@@ -180,10 +180,10 @@ func UpdateToken(c *gin.Context) {
return return
} }
if token.Status == common.TokenStatusEnabled { if token.Status == common.TokenStatusEnabled {
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() { if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "令牌已过期,无法启用,请先修改令牌过期时间", "message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期",
}) })
return return
} }

View File

@@ -36,7 +36,7 @@
"通过令牌「%s」使用模型 %s 消耗 %s模型倍率 %.2f,分组倍率 %.2f": "Using model %s with token %s consumes %s (model rate %.2f, group rate %.2f)", "通过令牌「%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.", "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。": "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", "令牌名称长度必须在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 token has expired and cannot be enabled. Please modify the expiration time of the token, or set it to never expire.",
"令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度": "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 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", "管理员关闭了密码登录": "The administrator has turned off password login",
"无法保存会话信息,请重试": "Unable to save session information, please try again", "无法保存会话信息,请重试": "Unable to save session information, please try again",
@@ -366,12 +366,12 @@
"添加新的用户": "Add New User", "添加新的用户": "Add New User",
"自定义": "Custom", "自定义": "Custom",
"等价金额": "Equivalent Amount", "等价金额": "Equivalent Amount",
"错误": "Error", "未登录或登录已过期,请重新登录": "Not logged in or login has expired, please log in again",
"错误:未登录或登录已过期,请重新登录": "Error: Not logged in or login has expired, please log in again", "请求次数过多,请稍后再试": "Too many requests, please try again later",
"错误:请求次数过多,请稍后再试": "Error: Too many requests, please try again later", "服务器内部错误,请联系管理员": "Server internal error, please contact the administrator",
"错误:服务器内部错误,请联系管理员": "Error: Server internal error, please contact the administrator",
"本站仅作演示之用,无服务端": "This site is for demonstration purposes only, no server-side", "本站仅作演示之用,无服务端": "This site is for demonstration purposes only, no server-side",
"错误:": "Error:", "超级管理员未设置充值链接!": "Super administrator has not set the recharge link!",
"错误:": "Error: ",
"新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面": "New version available: ${data.version}, please refresh the page using shortcut Shift + F5", "新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面": "New version available: ${data.version}, please refresh the page using shortcut Shift + F5",
"无法正常连接至服务器": "Unable to connect to the server normally", "无法正常连接至服务器": "Unable to connect to the server normally",
"管理渠道": "Manage Channels", "管理渠道": "Manage Channels",
@@ -426,5 +426,37 @@
"更新令牌信息": "Update Token Information", "更新令牌信息": "Update Token Information",
"请输入充值码!": "Please enter the recharge code!", "请输入充值码!": "Please enter the recharge code!",
"请输入名称": "Please enter a name", "请输入名称": "Please enter a name",
"请输入密钥,一行一个": "Please enter the key, one per line" "请输入密钥,一行一个": "Please enter the key, one per line",
} "请输入额度": "Please enter the quota",
"令牌创建成功": "Token created successfully",
"令牌更新成功": "Token updated successfully",
"充值成功!": "Recharge successful!",
"更新用户信息": "Update User Information",
"请输入新的用户名": "Please enter a new username",
"密码": "Password",
"请输入新的密码": "Please enter a new password",
"显示名称": "Display Name",
"请输入新的显示名称": "Please enter a new display name",
"已绑定的 GitHub 账户": "GitHub Account Bound",
"此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
"已绑定的微信账户": "WeChat Account Bound",
"已绑定的邮箱账户": "Email Account Bound",
"用户信息更新成功!": "User information updated successfully!",
"模型倍率 %.2f,分组倍率 %.2f": "model rate %.2f, group rate %.2f",
"使用明细(总消耗额度:{renderQuota(stat.quota)}": "Usage Details (Total Consumption Quota: {renderQuota(stat.quota)})",
"用户名称": "User Name",
"令牌名称": "Token Name",
"留空则查询全部用户": "Leave blank to query all users",
"留空则查询全部令牌": "Leave blank to query all tokens",
"模型名称": "Model Name",
"留空则查询全部模型": "Leave blank to query all models",
"起始时间": "Start Time",
"结束时间": "End Time",
"查询": "Query",
"提示": "Prompt",
"补全": "Completion",
"消耗额度": "Used Quota",
"可选值": "Optional Values",
"渠道不存在:%d": "Channel does not exist: %d",
"数据库一致性已被破坏,请联系管理员": "Database consistency has been broken, please contact the administrator"
}

11
main.go
View File

@@ -4,7 +4,6 @@ import (
"embed" "embed"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/sessions/cookie"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"one-api/common" "one-api/common"
"one-api/controller" "one-api/controller"
@@ -82,14 +81,8 @@ func main() {
server.Use(middleware.CORS()) server.Use(middleware.CORS())
// Initialize session store // Initialize session store
if common.RedisEnabled { store := cookie.NewStore([]byte(common.SessionSecret))
opt := common.ParseRedisOption() server.Use(sessions.Sessions("session", store))
store, _ := redis.NewStore(opt.MinIdleConns, opt.Network, opt.Addr, opt.Password, []byte(common.SessionSecret))
server.Use(sessions.Sessions("session", store))
} else {
store := cookie.NewStore([]byte(common.SessionSecret))
server.Use(sessions.Sessions("session", store))
}
router.SetRouter(server, buildFS, indexPage) router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT") var port = os.Getenv("PORT")

View File

@@ -75,9 +75,14 @@ func Distribute() func(c *gin.Context) {
} }
channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model) channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model)
if err != nil { if err != nil {
message := "无可用渠道"
if channel != nil {
common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
message = "数据库一致性已被破坏,请联系管理员"
}
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"error": gin.H{ "error": gin.H{
"message": "无可用渠道", "message": message,
"type": "one_api_error", "type": "one_api_error",
}, },
}) })
@@ -88,6 +93,7 @@ func Distribute() func(c *gin.Context) {
c.Set("channel", channel.Type) c.Set("channel", channel.Type)
c.Set("channel_id", channel.Id) c.Set("channel_id", channel.Id)
c.Set("channel_name", channel.Name) c.Set("channel_name", channel.Name)
c.Set("model_mapping", channel.ModelMapping)
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
c.Set("base_url", channel.BaseURL) c.Set("base_url", channel.BaseURL)
if channel.Type == common.ChannelTypeAzure { if channel.Type == common.ChannelTypeAzure {

View File

@@ -24,6 +24,7 @@ func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) {
return nil, err return nil, err
} }
channel := Channel{} channel := Channel{}
channel.Id = ability.ChannelId
err = DB.First(&channel, "id = ?", ability.ChannelId).Error err = DB.First(&channel, "id = ?", ability.ChannelId).Error
return &channel, err return &channel, err
} }

View File

@@ -83,6 +83,18 @@ func CacheGetUserQuota(id int) (quota int, err error) {
return quota, err return quota, err
} }
func CacheUpdateUserQuota(id int) error {
if !common.RedisEnabled {
return nil
}
quota, err := GetUserQuota(id)
if err != nil {
return err
}
err = common.RedisSet(fmt.Sprintf("user_quota:%d", id), fmt.Sprintf("%d", quota), UserId2QuotaCacheSeconds*time.Second)
return err
}
func CacheIsUserEnabled(userId int) bool { func CacheIsUserEnabled(userId int) bool {
if !common.RedisEnabled { if !common.RedisEnabled {
return IsUserEnabled(userId) return IsUserEnabled(userId)
@@ -108,7 +120,7 @@ var channelSyncLock sync.RWMutex
func InitChannelCache() { func InitChannelCache() {
newChannelId2channel := make(map[int]*Channel) newChannelId2channel := make(map[int]*Channel)
var channels []*Channel var channels []*Channel
DB.Find(&channels) DB.Where("status = ?", common.ChannelStatusEnabled).Find(&channels)
for _, channel := range channels { for _, channel := range channels {
newChannelId2channel[channel.Id] = channel newChannelId2channel[channel.Id] = channel
} }

View File

@@ -22,6 +22,7 @@ type Channel struct {
Models string `json:"models"` Models string `json:"models"`
Group string `json:"group" gorm:"type:varchar(32);default:'default'"` Group string `json:"group" gorm:"type:varchar(32);default:'default'"`
UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"` UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"`
ModelMapping string `json:"model_mapping" gorm:"type:varchar(1024);default:''"`
} }
func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) { func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) {
@@ -36,7 +37,7 @@ func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) {
} }
func SearchChannels(keyword string) (channels []*Channel, err error) { func SearchChannels(keyword string) (channels []*Channel, err error) {
err = DB.Omit("key").Where("id = ? or name LIKE ? or key = ?", keyword, 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 return channels, err
} }

View File

@@ -6,11 +6,17 @@ import (
) )
type Log struct { type Log struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"` UserId int `json:"user_id"`
CreatedAt int64 `json:"created_at" gorm:"bigint"` CreatedAt int64 `json:"created_at" gorm:"bigint;index"`
Type int `json:"type" gorm:"index"` Type int `json:"type" gorm:"index"`
Content string `json:"content"` Content string `json:"content"`
Username string `json:"username" gorm:"index;default:''"`
TokenName string `json:"token_name" gorm:"index;default:''"`
ModelName string `json:"model_name" gorm:"index;default:''"`
Quota int `json:"quota" gorm:"default:0"`
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
} }
const ( const (
@@ -27,6 +33,7 @@ func RecordLog(userId int, logType int, content string) {
} }
log := &Log{ log := &Log{
UserId: userId, UserId: userId,
Username: GetUsernameById(userId),
CreatedAt: common.GetTimestamp(), CreatedAt: common.GetTimestamp(),
Type: logType, Type: logType,
Content: content, Content: content,
@@ -37,24 +44,73 @@ func RecordLog(userId int, logType int, content string) {
} }
} }
func GetAllLogs(logType int, startIdx int, num int) (logs []*Log, err error) { func RecordConsumeLog(userId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int, content string) {
if !common.LogConsumeEnabled {
return
}
log := &Log{
UserId: userId,
Username: GetUsernameById(userId),
CreatedAt: common.GetTimestamp(),
Type: LogTypeConsume,
Content: content,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TokenName: tokenName,
ModelName: modelName,
Quota: quota,
}
err := DB.Create(log).Error
if err != nil {
common.SysError("failed to record log: " + err.Error())
}
}
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int) (logs []*Log, err error) {
var tx *gorm.DB var tx *gorm.DB
if logType == LogTypeUnknown { if logType == LogTypeUnknown {
tx = DB tx = DB
} else { } else {
tx = DB.Where("type = ?", logType) tx = DB.Where("type = ?", logType)
} }
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
if username != "" {
tx = tx.Where("username = ?", username)
}
if tokenName != "" {
tx = tx.Where("token_name = ?", tokenName)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error
return logs, err return logs, err
} }
func GetUserLogs(userId int, logType int, startIdx int, num int) (logs []*Log, err error) { func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int) (logs []*Log, err error) {
var tx *gorm.DB var tx *gorm.DB
if logType == LogTypeUnknown { if logType == LogTypeUnknown {
tx = DB.Where("user_id = ?", userId) tx = DB.Where("user_id = ?", userId)
} else { } else {
tx = DB.Where("user_id = ? and type = ?", userId, logType) tx = DB.Where("user_id = ? and type = ?", userId, logType)
} }
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
if tokenName != "" {
tx = tx.Where("token_name = ?", tokenName)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
err = tx.Order("id desc").Limit(num).Offset(startIdx).Omit("id").Find(&logs).Error err = tx.Order("id desc").Limit(num).Offset(startIdx).Omit("id").Find(&logs).Error
return logs, err return logs, err
} }
@@ -68,3 +124,45 @@ 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 err = DB.Where("user_id = ? and type = ?", userId, keyword).Order("id desc").Limit(common.MaxRecentItems).Omit("id").Find(&logs).Error
return logs, err return logs, err
} }
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (quota int) {
tx := DB.Table("logs").Select("sum(quota)")
if username != "" {
tx = tx.Where("username = ?", username)
}
if tokenName != "" {
tx = tx.Where("token_name = ?", tokenName)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
tx.Where("type = ?", LogTypeConsume).Scan(&quota)
return quota
}
func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) {
tx := DB.Table("logs").Select("sum(prompt_tokens) + sum(completion_tokens)")
if username != "" {
tx = tx.Where("username = ?", username)
}
if tokenName != "" {
tx = tx.Where("token_name = ?", tokenName)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
tx.Where("type = ?", LogTypeConsume).Scan(&token)
return token
}

View File

@@ -34,6 +34,7 @@ func InitOptionMap() {
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled)
common.OptionMap["ApproximateTokenEnabled"] = strconv.FormatBool(common.ApproximateTokenEnabled)
common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled)
common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled)
common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled)
@@ -141,6 +142,8 @@ func updateOptionMap(key string, value string) (err error) {
common.RegisterEnabled = boolValue common.RegisterEnabled = boolValue
case "AutomaticDisableChannelEnabled": case "AutomaticDisableChannelEnabled":
common.AutomaticDisableChannelEnabled = boolValue common.AutomaticDisableChannelEnabled = boolValue
case "ApproximateTokenEnabled":
common.ApproximateTokenEnabled = boolValue
case "LogConsumeEnabled": case "LogConsumeEnabled":
common.LogConsumeEnabled = boolValue common.LogConsumeEnabled = boolValue
case "DisplayInCurrencyEnabled": case "DisplayInCurrencyEnabled":

View File

@@ -3,6 +3,7 @@ package model
import ( import (
"errors" "errors"
"fmt" "fmt"
"gorm.io/gorm"
"one-api/common" "one-api/common"
) )
@@ -48,26 +49,27 @@ func Redeem(key string, userId int) (quota int, err error) {
return 0, errors.New("无效的 user id") return 0, errors.New("无效的 user id")
} }
redemption := &Redemption{} redemption := &Redemption{}
err = DB.Where("`key` = ?", key).First(redemption).Error
if err != nil { err = DB.Transaction(func(tx *gorm.DB) error {
return 0, errors.New("无效的兑换码") err := DB.Where("`key` = ?", key).First(redemption).Error
} if err != nil {
if redemption.Status != common.RedemptionCodeStatusEnabled { return errors.New("无效的兑换码")
return 0, errors.New("该兑换码已被使用") }
} if redemption.Status != common.RedemptionCodeStatusEnabled {
err = IncreaseUserQuota(userId, redemption.Quota) return errors.New("该兑换码已被使用")
if err != nil { }
return 0, err err = DB.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
} if err != nil {
go func() { return err
}
redemption.RedeemedTime = common.GetTimestamp() redemption.RedeemedTime = common.GetTimestamp()
redemption.Status = common.RedemptionCodeStatusUsed redemption.Status = common.RedemptionCodeStatusUsed
err := redemption.SelectUpdate() return redemption.SelectUpdate()
if err != nil { })
common.SysError("failed to update redemption status: " + err.Error()) if err != nil {
} return 0, errors.New("兑换失败," + err.Error())
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota))) }
}() RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota)))
return redemption.Quota, nil return redemption.Quota, nil
} }

View File

@@ -303,3 +303,8 @@ func UpdateUserUsedQuotaAndRequestCount(id int, quota int) {
common.SysError("failed to update user used quota and request count: " + err.Error()) common.SysError("failed to update user used quota and request count: " + err.Error())
} }
} }
func GetUsernameById(id int) (username string) {
DB.Model(&User{}).Where("id = ?", id).Select("username").Find(&username)
return username
}

View File

@@ -96,6 +96,8 @@ func SetApiRouter(router *gin.Engine) {
} }
logRoute := apiRouter.Group("/log") logRoute := apiRouter.Group("/log")
logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs) logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"net/http" "net/http"
"one-api/common"
"os" "os"
"strings" "strings"
) )
@@ -14,6 +15,10 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
SetDashboardRouter(router) SetDashboardRouter(router)
SetRelayRouter(router) SetRelayRouter(router)
frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL")
if common.IsMasterNode && frontendBaseUrl != "" {
frontendBaseUrl = ""
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
}
if frontendBaseUrl == "" { if frontendBaseUrl == "" {
SetWebRouter(router, buildFS, indexPage) SetWebRouter(router, buildFS, indexPage)
} else { } else {

View File

@@ -19,7 +19,7 @@ func SetRelayRouter(router *gin.Engine) {
{ {
relayV1Router.POST("/completions", controller.Relay) relayV1Router.POST("/completions", controller.Relay)
relayV1Router.POST("/chat/completions", controller.Relay) relayV1Router.POST("/chat/completions", controller.Relay)
relayV1Router.POST("/edits", controller.RelayNotImplemented) relayV1Router.POST("/edits", controller.Relay)
relayV1Router.POST("/images/generations", controller.RelayNotImplemented) relayV1Router.POST("/images/generations", controller.RelayNotImplemented)
relayV1Router.POST("/images/edits", controller.RelayNotImplemented) relayV1Router.POST("/images/edits", controller.RelayNotImplemented)
relayV1Router.POST("/images/variations", controller.RelayNotImplemented) relayV1Router.POST("/images/variations", controller.RelayNotImplemented)

View File

@@ -30,6 +30,9 @@ function renderType(type) {
function renderBalance(type, balance) { function renderBalance(type, balance) {
switch (type) { switch (type) {
case 1: // OpenAI case 1: // OpenAI
return <span>${balance.toFixed(2)}</span>;
case 4: // CloseAI
return <span>¥{balance.toFixed(2)}</span>;
case 8: // 自定义 case 8: // 自定义
return <span>${balance.toFixed(2)}</span>; return <span>${balance.toFixed(2)}</span>;
case 5: // OpenAI-SB case 5: // OpenAI-SB
@@ -38,6 +41,8 @@ function renderBalance(type, balance) {
return <span>{renderNumber(balance)}</span>; return <span>{renderNumber(balance)}</span>;
case 12: // API2GPT case 12: // API2GPT
return <span>¥{balance.toFixed(2)}</span>; return <span>¥{balance.toFixed(2)}</span>;
case 13: // AIGC2D
return <span>{renderNumber(balance)}</span>;
default: default:
return <span>不支持</span>; return <span>不支持</span>;
} }
@@ -58,8 +63,8 @@ const ChannelsTable = () => {
if (startIdx === 0) { if (startIdx === 0) {
setChannels(data); setChannels(data);
} else { } else {
let newChannels = channels; let newChannels = [...channels];
newChannels.push(...data); newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
setChannels(newChannels); setChannels(newChannels);
} }
} else { } else {
@@ -80,7 +85,7 @@ const ChannelsTable = () => {
const refresh = async () => { const refresh = async () => {
setLoading(true); setLoading(true);
await loadChannels(0); await loadChannels(activePage - 1);
}; };
useEffect(() => { useEffect(() => {
@@ -238,7 +243,7 @@ const ChannelsTable = () => {
if (channels.length === 0) return; if (channels.length === 0) return;
setLoading(true); setLoading(true);
let sortedChannels = [...channels]; let sortedChannels = [...channels];
if (typeof sortedChannels[0][key] === 'string'){ if (typeof sortedChannels[0][key] === 'string') {
sortedChannels.sort((a, b) => { sortedChannels.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]); return ('' + a[key]).localeCompare(b[key]);
}); });

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Label, Pagination, Select, Table } from 'semantic-ui-react'; import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react';
import { API, isAdmin, showError, timestamp2string } from '../helpers'; import { API, isAdmin, showError, timestamp2string } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return (
@@ -14,7 +15,7 @@ function renderTimestamp(timestamp) {
const MODE_OPTIONS = [ const MODE_OPTIONS = [
{ key: 'all', text: '全部用户', value: 'all' }, { key: 'all', text: '全部用户', value: 'all' },
{ key: 'self', text: '当前用户', value: 'self' }, { key: 'self', text: '当前用户', value: 'self' }
]; ];
const LOG_OPTIONS = [ const LOG_OPTIONS = [
@@ -47,13 +48,58 @@ const LogsTable = () => {
const [searchKeyword, setSearchKeyword] = useState(''); const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [logType, setLogType] = useState(0); const [logType, setLogType] = useState(0);
const [mode, setMode] = useState('self'); // all, self const isAdminUser = isAdmin();
const showModePanel = isAdmin(); let now = new Date();
const [inputs, setInputs] = useState({
username: '',
token_name: '',
model_name: '',
start_timestamp: timestamp2string(0),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600)
});
const { username, token_name, model_name, start_timestamp, end_timestamp } = inputs;
const [stat, setStat] = useState({
quota: 0,
token: 0
});
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const getLogSelfStat = async () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let res = await API.get(`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`);
const { success, message, data } = res.data;
if (success) {
setStat(data);
} else {
showError(message);
}
};
const getLogStat = async () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`);
const { success, message, data } = res.data;
if (success) {
setStat(data);
} else {
showError(message);
}
};
const loadLogs = async (startIdx) => { const loadLogs = async (startIdx) => {
let url = `/api/log/self/?p=${startIdx}&type=${logType}`; let url = '';
if (mode === 'all') { let localStartTimestamp = Date.parse(start_timestamp) / 1000;
url = `/api/log/?p=${startIdx}&type=${logType}`; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) {
url = `/api/log/?p=${startIdx}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/log/self/?p=${startIdx}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} }
const res = await API.get(url); const res = await API.get(url);
const { success, message, data } = res.data; const { success, message, data } = res.data;
@@ -61,8 +107,8 @@ const LogsTable = () => {
if (startIdx === 0) { if (startIdx === 0) {
setLogs(data); setLogs(data);
} else { } else {
let newLogs = logs; let newLogs = [...logs];
newLogs.push(...data); newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
setLogs(newLogs); setLogs(newLogs);
} }
} else { } else {
@@ -83,20 +129,18 @@ const LogsTable = () => {
const refresh = async () => { const refresh = async () => {
setLoading(true); setLoading(true);
setActivePage(1)
await loadLogs(0); await loadLogs(0);
if (isAdminUser) {
getLogStat().then();
} else {
getLogSelfStat().then();
}
}; };
useEffect(() => {
loadLogs(0)
.then()
.catch((reason) => {
showError(reason);
});
}, []);
useEffect(() => { useEffect(() => {
refresh().then(); refresh().then();
}, [mode, logType]); }, [logType]);
const searchLogs = async () => { const searchLogs = async () => {
if (searchKeyword === '') { if (searchKeyword === '') {
@@ -125,9 +169,17 @@ const LogsTable = () => {
if (logs.length === 0) return; if (logs.length === 0) return;
setLoading(true); setLoading(true);
let sortedLogs = [...logs]; let sortedLogs = [...logs];
sortedLogs.sort((a, b) => { if (typeof sortedLogs[0][key] === 'string'){
return ('' + a[key]).localeCompare(b[key]); sortedLogs.sort((a, b) => {
}); return ('' + a[key]).localeCompare(b[key]);
});
} else {
sortedLogs.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 (sortedLogs[0].id === logs[0].id) { if (sortedLogs[0].id === logs[0].id) {
sortedLogs.reverse(); sortedLogs.reverse();
} }
@@ -137,118 +189,178 @@ const LogsTable = () => {
return ( return (
<> <>
<Table basic> <Segment>
<Table.Header> <Header as='h3'>使用明细总消耗额度{renderQuota(stat.quota)}</Header>
<Table.Row> <Form>
<Table.HeaderCell <Form.Group>
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('created_time');
}}
width={3}
>
时间
</Table.HeaderCell>
{ {
showModePanel && ( isAdminUser && (
<Table.HeaderCell <Form.Input fluid label={'用户名称'} width={2} value={username}
placeholder={'可选值'} name='username'
onChange={handleInputChange} />
)
}
<Form.Input fluid label={'令牌名称'} width={isAdminUser ? 2 : 3} value={token_name}
placeholder={'可选值'} name='token_name' onChange={handleInputChange} />
<Form.Input fluid label='模型名称' width={isAdminUser ? 2 : 3} value={model_name} placeholder='可选值'
name='model_name'
onChange={handleInputChange} />
<Form.Input fluid label='起始时间' width={4} value={start_timestamp} type='datetime-local'
name='start_timestamp'
onChange={handleInputChange} />
<Form.Input fluid label='结束时间' width={4} value={end_timestamp} type='datetime-local'
name='end_timestamp'
onChange={handleInputChange} />
<Form.Button fluid label='操作' width={2} onClick={refresh}>查询</Form.Button>
</Form.Group>
</Form>
<Table basic compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('created_time');
}}
width={3}
>
时间
</Table.HeaderCell>
{
isAdminUser && <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
sortLog('user_id'); sortLog('username');
}} }}
width={1} width={1}
> >
用户 用户
</Table.HeaderCell> </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 <Table.HeaderCell
placeholder='选择明细分类' style={{ cursor: 'pointer' }}
options={LOG_OPTIONS} onClick={() => {
style={{ marginRight: '8px' }} sortLog('token_name');
name='logType'
value={logType}
onChange={(e, { name, value }) => {
setLogType(value);
}} }}
/> width={1}
<Button size='small' onClick={refresh} loading={loading}>刷新</Button> >
<Pagination 令牌
floated='right' </Table.HeaderCell>
activePage={activePage} <Table.HeaderCell
onPageChange={onPaginationChange} style={{ cursor: 'pointer' }}
size='small' onClick={() => {
siblingRange={1} sortLog('type');
totalPages={ }}
Math.ceil(logs.length / ITEMS_PER_PAGE) + width={1}
(logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0) >
} 类型
/> </Table.HeaderCell>
</Table.HeaderCell> <Table.HeaderCell
</Table.Row> style={{ cursor: 'pointer' }}
</Table.Footer> onClick={() => {
</Table> sortLog('model_name');
}}
width={2}
>
模型
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('prompt_tokens');
}}
width={1}
>
提示
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('completion_tokens');
}}
width={1}
>
补全
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('quota');
}}
width={2}
>
消耗额度
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('content');
}}
width={isAdminUser ? 4 : 5}
>
详情
</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>
{
isAdminUser && (
<Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell>
)
}
<Table.Cell>{log.token_name ? <Label basic>{log.token_name}</Label> : ''}</Table.Cell>
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>{log.model_name ? <Label basic>{log.model_name}</Label> : ''}</Table.Cell>
<Table.Cell>{log.prompt_tokens ? log.prompt_tokens : ''}</Table.Cell>
<Table.Cell>{log.completion_tokens ? log.completion_tokens : ''}</Table.Cell>
<Table.Cell>{log.quota ? renderQuota(log.quota, 6) : ''}</Table.Cell>
<Table.Cell>{log.content}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={'9'}>
<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>
</Segment>
</> </>
); );
}; };

View File

@@ -18,7 +18,8 @@ const OperationSetting = () => {
ChannelDisableThreshold: 0, ChannelDisableThreshold: 0,
LogConsumeEnabled: '', LogConsumeEnabled: '',
DisplayInCurrencyEnabled: '', DisplayInCurrencyEnabled: '',
DisplayTokenStatEnabled: '' DisplayTokenStatEnabled: '',
ApproximateTokenEnabled: '',
}); });
const [originInputs, setOriginInputs] = useState({}); const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
@@ -74,9 +75,6 @@ const OperationSetting = () => {
const submitConfig = async (group) => { const submitConfig = async (group) => {
switch (group) { switch (group) {
case 'monitor': case 'monitor':
if (originInputs['AutomaticDisableChannelEnabled'] !== inputs.AutomaticDisableChannelEnabled) {
await updateOption('AutomaticDisableChannelEnabled', inputs.AutomaticDisableChannelEnabled);
}
if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) { if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) {
await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold); await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold);
} }
@@ -184,6 +182,12 @@ const OperationSetting = () => {
name='DisplayTokenStatEnabled' name='DisplayTokenStatEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox
checked={inputs.ApproximateTokenEnabled === 'true'}
label='使用近似的方式估算 token 数以减少计算量'
name='ApproximateTokenEnabled'
onChange={handleInputChange}
/>
</Form.Group> </Form.Group>
<Form.Button onClick={() => { <Form.Button onClick={() => {
submitConfig('general').then(); submitConfig('general').then();

View File

@@ -45,8 +45,8 @@ const TokensTable = () => {
if (startIdx === 0) { if (startIdx === 0) {
setTokens(data); setTokens(data);
} else { } else {
let newTokens = tokens; let newTokens = [...tokens];
newTokens.push(...data); newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
setTokens(newTokens); setTokens(newTokens);
} }
} else { } else {
@@ -67,7 +67,7 @@ const TokensTable = () => {
const refresh = async () => { const refresh = async () => {
setLoading(true); setLoading(true);
await loadTokens(0); await loadTokens(activePage - 1);
} }
useEffect(() => { useEffect(() => {

View File

@@ -183,14 +183,6 @@ const UsersTable = () => {
> >
分组 分组
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('email');
}}
>
邮箱地址
</Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
@@ -233,20 +225,19 @@ const UsersTable = () => {
<Table.Cell> <Table.Cell>
<Popup <Popup
content={user.email ? user.email : '未绑定邮箱地址'} content={user.email ? user.email : '未绑定邮箱地址'}
key={user.display_name} key={user.username}
header={user.display_name ? user.display_name : user.username}
trigger={<span>{renderText(user.username, 10)}</span>} trigger={<span>{renderText(user.username, 10)}</span>}
hoverable hoverable
/> />
</Table.Cell> </Table.Cell>
<Table.Cell>{renderGroup(user.group)}</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> <Table.Cell>
{user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'} <Popup content='剩余额度' trigger={<Label basic>{renderQuota(user.quota)}</Label>} />
</Table.Cell> <Popup content='已用额度' trigger={<Label basic>{renderQuota(user.used_quota)}</Label>} />
<Table.Cell> <Popup content='请求次数' trigger={<Label basic>{renderNumber(user.request_count)}</Label>} />
<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>
<Table.Cell>{renderRole(user.role)}</Table.Cell> <Table.Cell>{renderRole(user.role)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell> <Table.Cell>{renderStatus(user.status)}</Table.Cell>
@@ -320,7 +311,7 @@ const UsersTable = () => {
<Table.Footer> <Table.Footer>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan='8'> <Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/user/add' loading={loading}> <Button size='small' as={Link} to='/user/add' loading={loading}>
添加新的用户 添加新的用户
</Button> </Button>

View File

@@ -9,5 +9,6 @@ export const CHANNEL_OPTIONS = [
{ key: 7, text: 'OhMyGPT', value: 7, color: 'purple' }, { key: 7, text: 'OhMyGPT', value: 7, color: 'purple' },
{ key: 9, text: 'AI.LS', value: 9, color: 'yellow' }, { 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' } { key: 12, text: 'API2GPT', value: 12, color: 'blue' },
]; { key: 13, text: 'AIGC2D', value: 13, color: 'purple' }
];

View File

@@ -1,9 +1,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react'; import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { API, showError, showInfo, showSuccess } from '../../helpers'; import { API, showError, showInfo, showSuccess, verifyJSON } from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants'; import { CHANNEL_OPTIONS } from '../../constants';
const MODEL_MAPPING_EXAMPLE = {
'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
'gpt-4-0314': 'gpt-4',
'gpt-4-32k-0314': 'gpt-4-32k'
};
const EditChannel = () => { const EditChannel = () => {
const params = useParams(); const params = useParams();
const channelId = params.id; const channelId = params.id;
@@ -15,6 +21,7 @@ const EditChannel = () => {
key: '', key: '',
base_url: '', base_url: '',
other: '', other: '',
model_mapping: '',
models: [], models: [],
groups: ['default'] groups: ['default']
}; };
@@ -42,6 +49,9 @@ const EditChannel = () => {
} else { } else {
data.groups = data.group.split(','); data.groups = data.group.split(',');
} }
if (data.model_mapping !== '') {
data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2);
}
setInputs(data); setInputs(data);
} else { } else {
showError(message); showError(message);
@@ -94,6 +104,10 @@ const EditChannel = () => {
showInfo('请至少选择一个模型!'); showInfo('请至少选择一个模型!');
return; return;
} }
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
showInfo('模型映射必须是合法的 JSON 格式!');
return;
}
let localInputs = inputs; let localInputs = inputs;
if (localInputs.base_url.endsWith('/')) { if (localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1); localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
@@ -131,6 +145,7 @@ const EditChannel = () => {
<Form.Select <Form.Select
label='类型' label='类型'
name='type' name='type'
required
options={CHANNEL_OPTIONS} options={CHANNEL_OPTIONS}
value={inputs.type} value={inputs.type}
onChange={handleInputChange} onChange={handleInputChange}
@@ -187,7 +202,7 @@ const EditChannel = () => {
<Form.Input <Form.Input
label='镜像' label='镜像'
name='base_url' name='base_url'
placeholder={'输入镜像站地址格式为https://domain.com,可不填,不填则使用渠道默认值'} placeholder={'此项可选,输入镜像站地址格式为https://domain.com'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='new-password' autoComplete='new-password'
@@ -198,6 +213,7 @@ const EditChannel = () => {
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='名称' label='名称'
required
name='name' name='name'
placeholder={'请输入名称'} placeholder={'请输入名称'}
onChange={handleInputChange} onChange={handleInputChange}
@@ -210,6 +226,7 @@ const EditChannel = () => {
label='分组' label='分组'
placeholder={'请选择分组'} placeholder={'请选择分组'}
name='groups' name='groups'
required
fluid fluid
multiple multiple
selection selection
@@ -226,6 +243,7 @@ const EditChannel = () => {
label='模型' label='模型'
placeholder={'请选择该通道所支持的模型'} placeholder={'请选择该通道所支持的模型'}
name='models' name='models'
required
fluid fluid
multiple multiple
selection selection
@@ -246,11 +264,23 @@ const EditChannel = () => {
handleInputChange(null, { name: 'models', value: [] }); handleInputChange(null, { name: 'models', value: [] });
}}>清除所有模型</Button> }}>清除所有模型</Button>
</div> </div>
<Form.Field>
<Form.TextArea
label='模型映射'
placeholder={`此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
name='model_mapping'
onChange={handleInputChange}
value={inputs.model_mapping}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
/>
</Form.Field>
{ {
batch ? <Form.Field> batch ? <Form.Field>
<Form.TextArea <Form.TextArea
label='密钥' label='密钥'
name='key' name='key'
required
placeholder={'请输入密钥,一行一个'} placeholder={'请输入密钥,一行一个'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.key} value={inputs.key}
@@ -261,6 +291,7 @@ const EditChannel = () => {
<Form.Input <Form.Input
label='密钥' label='密钥'
name='key' name='key'
required
placeholder={'请输入密钥'} placeholder={'请输入密钥'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.key} value={inputs.key}

View File

@@ -4,10 +4,7 @@ import LogsTable from '../../components/LogsTable';
const Token = () => ( const Token = () => (
<> <>
<Segment> <LogsTable />
<Header as='h3'>额度明细</Header>
<LogsTable />
</Segment>
</> </>
); );

View File

@@ -11,7 +11,7 @@ const EditToken = () => {
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const originInputs = { const originInputs = {
name: '', name: '',
remain_quota: 0, remain_quota: isEdit ? 0 : 500000,
expired_time: -1, expired_time: -1,
unlimited_quota: false unlimited_quota: false
}; };

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react'; import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers'; import { API, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
const EditUser = () => { const EditUser = () => {
const params = useParams(); const params = useParams();
@@ -134,7 +135,7 @@ const EditUser = () => {
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='剩余额度' label={`剩余额度${renderQuotaWithPrompt(quota)}`}
name='quota' name='quota'
placeholder={'请输入新的剩余额度'} placeholder={'请输入新的剩余额度'}
onChange={handleInputChange} onChange={handleInputChange}