diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd688493 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: 报告问题 +about: 使用简练详细的语言描述你遇到的问题 +title: '' +labels: bug +assignees: '' + +--- + +**例行检查** + +[//]: # (方框内删除已有的空格,填 x 号) ++ [ ] 我已确认目前没有类似 issue ++ [ ] 我已确认我已升级到最新版本 ++ [ ] 我已完整查看过项目 README,尤其是常见问题部分 ++ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 ++ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** + +**问题描述** + +**复现步骤** + +**预期结果** + +**相关截图** +如果没有的话,请删除此节。 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..83a0f3f4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +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: 请作者喝杯咖啡,以激励作者持续开发 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..049d89c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: 功能请求 +about: 使用简练详细的语言描述希望加入的新功能 +title: '' +labels: enhancement +assignees: '' + +--- + +**例行检查** + +[//]: # (方框内删除已有的空格,填 x 号) ++ [ ] 我已确认目前没有类似 issue ++ [ ] 我已确认我已升级到最新版本 ++ [ ] 我已完整查看过项目 README,已确定现有版本无法满足需求 ++ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 ++ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** + +**功能描述** + +**应用场景** diff --git a/.github/workflows/docker-image-amd64-en.yml b/.github/workflows/docker-image-amd64-en.yml new file mode 100644 index 00000000..44dc0bc0 --- /dev/null +++ b/.github/workflows/docker-image-amd64-en.yml @@ -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 }} \ No newline at end of file diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml index 7304e5c9..d6449eb8 100644 --- a/.github/workflows/docker-image-arm64.yml +++ b/.github/workflows/docker-image-arm64.yml @@ -4,6 +4,7 @@ on: push: tags: - '*' + - '!*-alpha*' workflow_dispatch: inputs: name: diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 6833a901..364b83ae 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -6,6 +6,7 @@ on: push: tags: - '*' + - '!*-alpha*' jobs: release: runs-on: ubuntu-latest diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 5ec789c1..bdd0d208 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -6,6 +6,7 @@ on: push: tags: - '*' + - '!*-alpha*' jobs: release: runs-on: macos-latest diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index fa5bb995..33193a89 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -6,6 +6,7 @@ on: push: tags: - '*' + - '!*-alpha*' jobs: release: runs-on: windows-latest diff --git a/.gitignore b/.gitignore index 0b2856cc..60abb13e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ upload *.exe *.db build -*.db-journal \ No newline at end of file +*.db-journal +logs +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4afbf100..ffb8c21b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,11 @@ FROM node:16 as builder WORKDIR /build +COPY web/package.json . +RUN npm install COPY ./web . COPY ./VERSION . -RUN npm install -RUN REACT_APP_VERSION=$(cat VERSION) npm run build +RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build FROM golang AS builder2 @@ -13,9 +14,10 @@ ENV GO111MODULE=on \ GOOS=linux WORKDIR /build +ADD go.mod go.sum ./ +RUN go mod download COPY . . COPY --from=builder /build/build ./web/build -RUN go mod download RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)' -extldflags '-static'" -o one-api FROM alpine diff --git a/README.en.md b/README.en.md new file mode 100644 index 00000000..9345a219 --- /dev/null +++ b/README.en.md @@ -0,0 +1,299 @@ +

+ 中文 | English | 日本語 +

+ +

+ one-api logo +

+ +
+ +# One API + +_✨ Access all LLM through the standard OpenAI API format, easy to deploy & use ✨_ + +
+ +

+ + license + + + release + + + docker pull + + + release + + + GoReportCard + +

+ +

+ Deployment Tutorial + · + Usage + · + Feedback + · + Screenshots + · + Live Demo + · + FAQ + · + Related Projects + · + Donate +

+ +> **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. Support for multiple large models: + + [x] [OpenAI ChatGPT Series Models](https://platform.openai.com/docs/guides/gpt/chat-completions-api) (Supports [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference)) + + [x] [Anthropic Claude Series Models](https://anthropic.com) + + [x] [Google PaLM2 Series Models](https://developers.generativeai.google) + + [x] [Baidu Wenxin Yiyuan Series Models](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html) + + [x] [Alibaba Tongyi Qianwen Series Models](https://help.aliyun.com/document_detail/2400395.html) + + [x] [Zhipu ChatGLM Series Models](https://bigmodel.cn) +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 +
+Deploy on Sealos +
+ +> Sealos supports high concurrency, dynamic scaling, and stable operations for millions of users. + +> Click the button below to deploy with one click.👇 + +[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api) + + +
+
+ +
+Deployment on Zeabur +
+ +> Zeabur's servers are located overseas, automatically solving network issues, and the free quota is sufficient for personal usage. + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/7Q0KO3) + +1. First, fork the code. +2. Go to [Zeabur](https://zeabur.com?referralCode=songquanpeng), 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 `:@tcp(:)/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. + +
+
+ +## 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. `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` + +### Command Line Parameters +1. `--port `: Specifies the port number on which the server listens. Defaults to `3000`. + + Example: `--port 3000` +2. `--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/labring/FastGPT): Knowledge question answering system based on the LLM + +## 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 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. diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 00000000..6faf9bee --- /dev/null +++ b/README.ja.md @@ -0,0 +1,300 @@ +

+ 中文 | English | 日本語 +

+ +

+ one-api logo +

+ +
+ +# One API + +_✨ 標準的な OpenAI API フォーマットを通じてすべての LLM にアクセスでき、導入と利用が容易です ✨_ + +
+ +

+ + license + + + release + + + docker pull + + + release + + + GoReportCard + +

+ +

+ デプロイチュートリアル + · + 使用方法 + · + フィードバック + · + スクリーンショット + · + ライブデモ + · + FAQ + · + 関連プロジェクト + · + 寄付 +

+ +> **警告**: この README は ChatGPT によって翻訳されています。翻訳ミスを発見した場合は遠慮なく PR を投稿してください。 + +> **警告**: 英語版の Docker イメージは `justsong/one-api-en` です。 + +> **注**: Docker からプルされた最新のイメージは、`alpha` リリースかもしれません。安定性が必要な場合は、手動でバージョンを指定してください。 + +## 特徴 +1. 複数の大型モデルをサポート: + + [x] [OpenAI ChatGPT シリーズモデル](https://platform.openai.com/docs/guides/gpt/chat-completions-api) ([Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) をサポート) + + [x] [Anthropic Claude シリーズモデル](https://anthropic.com) + + [x] [Google PaLM2 シリーズモデル](https://developers.generativeai.google) + + [x] [Baidu Wenxin Yiyuan シリーズモデル](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html) + + [x] [Alibaba Tongyi Qianwen シリーズモデル](https://help.aliyun.com/document_detail/2400395.html) + + [x] [Zhipu ChatGLM シリーズモデル](https://bigmodel.cn) +2. **ロードバランシング**による複数チャンネルへのアクセスをサポート。 +3. ストリーム伝送によるタイプライター的効果を可能にする**ストリームモード**に対応。 +4. **マルチマシンデプロイ**に対応。[詳細はこちら](#multi-machine-deployment)を参照。 +5. トークンの有効期限や使用回数を設定できる**トークン管理**に対応しています。 +6. **バウチャー管理**に対応しており、バウチャーの一括生成やエクスポートが可能です。バウチャーは口座残高の補充に利用できます。 +7. **チャンネル管理**に対応し、チャンネルの一括作成が可能。 +8. グループごとに異なるレートを設定するための**ユーザーグループ**と**チャンネルグループ**をサポートしています。 +9. チャンネル**モデルリスト設定**に対応。 +10. **クォータ詳細チェック**をサポート。 +11. **ユーザー招待報酬**をサポートします。 +12. 米ドルでの残高表示が可能。 +13. 新規ユーザー向けのお知らせ公開、リチャージリンク設定、初期残高設定に対応。 +14. 豊富な**カスタマイズ**オプションを提供します: + 1. システム名、ロゴ、フッターのカスタマイズが可能。 + 2. HTML と Markdown コードを使用したホームページとアバウトページのカスタマイズ、または iframe を介したスタンドアロンウェブページの埋め込みをサポートしています。 +15. システム・アクセストークンによる管理 API アクセスをサポートする。 +16. Cloudflare Turnstile によるユーザー認証に対応。 +17. ユーザー管理と複数のユーザーログイン/登録方法をサポート: + + 電子メールによるログイン/登録とパスワードリセット。 + + [GitHub OAuth](https://github.com/settings/applications/new)。 + + WeChat 公式アカウントの認証([WeChat Server](https://github.com/songquanpeng/wechat-server)の追加導入が必要)。 +18. 他の主要なモデル API が利用可能になった場合、即座にサポートし、カプセル化する。 + +## デプロイメント +### 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-en`。 + +コマンドを更新する: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrr/watchtower -cR`。 + +`-p 3000:3000` の最初の `3000` はホストのポートで、必要に応じて変更できます。 + +データはホストの `/home/ubuntu/data/one-api` ディレクトリに保存される。このディレクトリが存在し、書き込み権限があることを確認する、もしくは適切なディレクトリに変更してください。 + +Nginxリファレンス設定: +``` +server{ + server_name openai.justsong.cn; # ドメイン名は適宜変更 + + location / { + client_max_body_size 64m; + proxy_http_version 1.1; + proxy_pass http://localhost:3000; # それに応じてポートを変更 + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_cache_bypass $http_upgrade; + proxy_set_header Accept-Encoding gzip; + proxy_read_timeout 300s; # GPT-4 はより長いタイムアウトが必要 + } +} +``` + +次に、Let's Encrypt certbot を使って HTTPS を設定します: +```bash +# Ubuntu に certbot をインストール: +sudo snap install --classic certbot +sudo ln -s /snap/bin/certbot /usr/bin/certbot +# 証明書の生成と Nginx 設定の変更 +sudo certbot --nginx +# プロンプトに従う +# Nginx を再起動 +sudo service nginx restart +``` + +初期アカウントのユーザー名は `root` で、パスワードは `123456` です。 + +### マニュアルデプロイ +1. [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) から実行ファイルをダウンロードする、もしくはソースからコンパイルする: + ```shell + git clone https://github.com/songquanpeng/one-api.git + + # フロントエンドのビルド + cd one-api/web + npm install + npm run build + + # バックエンドのビルド + cd .. + go mod download + go build -ldflags "-s -w" -o one-api + ``` +2. 実行: + ```shell + chmod u+x one-api + ./one-api --port 3000 --log-dir ./logs + ``` +3. [http://localhost:3000/](http://localhost:3000/) にアクセスし、ログインする。初期アカウントのユーザー名は `root`、パスワードは `123456` である。 + +より詳細なデプロイのチュートリアルについては、[このページ](https://iamazing.cn/page/how-to-deploy-a-website) を参照してください。 + +### マルチマシンデプロイ +1. すべてのサーバに同じ `SESSION_SECRET` を設定する。 +2. `SQL_DSN` を設定し、SQLite の代わりに MySQL を使用する。すべてのサーバは同じデータベースに接続する。 +3. マスターノード以外のノードの `NODE_TYPE` を `slave` に設定する。 +4. データベースから定期的に設定を同期するサーバーには `SYNC_FREQUENCY` を設定する。 +5. マスター以外のノードでは、オプションで `FRONTEND_BASE_URL` を設定して、ページ要求をマスターサーバーにリダイレクトすることができます。 +6. マスター以外のノードには Redis を個別にインストールし、`REDIS_CONN_STRING` を設定して、キャッシュの有効期限が切れていないときにデータベースにゼロレイテンシーでアクセスできるようにする。 +7. メインサーバーでもデータベースへのアクセスが高レイテンシになる場合は、Redis を有効にし、`SYNC_FREQUENCY` を設定してデータベースから定期的に設定を同期する必要がある。 + +Please refer to the [environment variables](#environment-variables) section for details on using environment variables. + +### コントロールパネル(例: Baota)への展開 +詳しい手順は [#175](https://github.com/songquanpeng/one-api/issues/175) を参照してください。 + +配置後に空白のページが表示される場合は、[#97](https://github.com/songquanpeng/one-api/issues/97) を参照してください。 + +### サードパーティプラットフォームへのデプロイ +
+Sealos へのデプロイ +
+ +> Sealos は、高い同時実行性、ダイナミックなスケーリング、数百万人のユーザーに対する安定した運用をサポートしています。 + +> 下のボタンをクリックすると、ワンクリックで展開できます。👇 + +[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api) + + +
+
+ +
+Zeabur へのデプロイ +
+ +> Zeabur のサーバーは海外にあるため、ネットワークの問題は自動的に解決されます。 + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/7Q0KO3) + +1. まず、コードをフォークする。 +2. [Zeabur](https://zeabur.com?referralCode=songquanpeng) にアクセスしてログインし、コンソールに入る。 +3. 新しいプロジェクトを作成します。Service -> Add ServiceでMarketplace を選択し、MySQL を選択する。接続パラメータ(ユーザー名、パスワード、アドレス、ポート)をメモします。 +4. 接続パラメータをコピーし、```create database `one-api` ``` を実行してデータベースを作成する。 +5. その後、Service -> Add Service で Git を選択し(最初の使用には認証が必要です)、フォークしたリポジトリを選択します。 +6. 自動デプロイが開始されますが、一旦キャンセルしてください。Variable タブで `PORT` に `3000` を追加し、`SQL_DSN` に `:@tcp(:)/one-api` を追加します。変更を保存する。SQL_DSN` が設定されていないと、データが永続化されず、再デプロイ後にデータが失われるので注意すること。 +7. 再デプロイを選択します。 +8. Domains タブで、"my-one-api" のような適切なドメイン名の接頭辞を選択する。最終的なドメイン名は "my-one-api.zeabur.app" となります。独自のドメイン名を CNAME することもできます。 +9. デプロイが完了するのを待ち、生成されたドメイン名をクリックして One API にアクセスします。 + +
+
+ +## コンフィグ +システムは箱から出してすぐに使えます。 + +環境変数やコマンドラインパラメータを設定することで、システムを構成することができます。 + +システム起動後、`root` ユーザーとしてログインし、さらにシステムを設定します。 + +## 使用方法 +`Channels` ページで API Key を追加し、`Tokens` ページでアクセストークンを追加する。 + +アクセストークンを使って 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 を指定するためには、トークンは管理者によって作成される必要があることに注意してください。 + +もしチャネル ID が指定されない場合、ロードバランシングによってリクエストが複数のチャネルに振り分けられます。 + +### 環境変数 +1. `REDIS_CONN_STRING`: 設定すると、リクエストレート制限のためのストレージとして、メモリの代わりに Redis が使われる。 + + 例: `REDIS_CONN_STRING=redis://default:redispw@localhost:49153` +2. `SESSION_SECRET`: 設定すると、固定セッションキーが使用され、システムの再起動後もログインユーザーのクッキーが有効であることが保証されます。 + + 例: `SESSION_SECRET=random_string` +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. `POLLING_INTERVAL`: チャネル残高の更新とチャネルの可用性をテストするときのリクエスト間の時間間隔 (秒)。デフォルトは間隔なし。 + + 例: `POLLING_INTERVAL=5` + +### コマンドラインパラメータ +1. `--port `: サーバがリッスンするポート番号を指定。デフォルトは `3000` です。 + + 例: `--port 3000` +2. `--log-dir `: ログディレクトリを指定。設定しない場合、ログは保存されません。 + + 例: `--log-dir ./logs` +3. `--version`: システムのバージョン番号を表示して終了する。 +4. `--help`: コマンドの使用法ヘルプとパラメータの説明を表示。 + +## スクリーンショット +![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. ノルマとは何か?どのように計算されますか?One API にはノルマ計算の問題はありますか? + + ノルマ = グループ倍率 * モデル倍率 * (プロンプトトークンの数 + 完了トークンの数 * 完了倍率) + + 完了倍率は、公式の定義と一致するように、GPT3.5 では 1.33、GPT4 では 2 に固定されています。 + + ストリームモードでない場合、公式 API は消費したトークンの総数を返す。ただし、プロンプトとコンプリートの消費倍率は異なるので注意してください。 +2. アカウント残高は十分なのに、"insufficient quota" と表示されるのはなぜですか? + + トークンのクォータが十分かどうかご確認ください。トークンクォータはアカウント残高とは別のものです。 + + トークンクォータは最大使用量を設定するためのもので、ユーザーが自由に設定できます。 +3. チャンネルを使おうとすると "No available channels" と表示されます。どうすればいいですか? + + ユーザーとチャンネルグループの設定を確認してください。 + + チャンネルモデルの設定も確認してください。 +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/labring/FastGPT): LLM に基づく知識質問応答システム + +## 注 +本プロジェクトはオープンソースプロジェクトです。OpenAI の[利用規約](https://openai.com/policies/terms-of-use)および**適用される法令**を遵守してご利用ください。違法な目的での利用はご遠慮ください。 + +このプロジェクトは MIT ライセンスで公開されています。これに基づき、ページの最下部に帰属表示と本プロジェクトへのリンクを含める必要があります。 + +このプロジェクトを基にした派生プロジェクトについても同様です。 + +帰属表示を含めたくない場合は、事前に許可を得なければなりません。 + +MIT ライセンスによると、このプロジェクトを利用するリスクと責任は利用者が負うべきであり、このオープンソースプロジェクトの開発者は責任を負いません。 diff --git a/README.md b/README.md index c972c600..4ef6505c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +

+ 中文 | English | 日本語 +

+ +

one-api logo

@@ -6,7 +11,7 @@ # One API -_✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用✨_ +_✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 ✨_ @@ -29,10 +34,10 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用

- 程序下载 - · 部署教程 · + 使用方法 + · 意见反馈 · 截图展示 @@ -40,47 +45,88 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用 在线演示 · 常见问题 + · + 相关项目 + · + 赞赏支持

-> **Warning**:从 `v0.2` 版本升级到 `v0.3` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.2-v0.3.sql)。 +> **Note** +> 本项目为开源项目,使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。 +> +> 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。 +> **Warning** +> 使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本。 + +> **Warning** +> 使用 root 用户初次登录系统后,务必修改默认密码 `123456`! ## 功能 -1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道: - + [x] OpenAI 官方通道 - + [x] **Azure OpenAI API** +1. 支持多种大模型: + + [x] [OpenAI ChatGPT 系列模型](https://platform.openai.com/docs/guides/gpt/chat-completions-api)(支持 [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference)) + + [x] [Anthropic Claude 系列模型](https://anthropic.com) + + [x] [Google PaLM2 系列模型](https://developers.generativeai.google) + + [x] [百度文心一言系列模型](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html) + + [x] [阿里通义千问系列模型](https://help.aliyun.com/document_detail/2400395.html) + + [x] [讯飞星火认知大模型](https://www.xfyun.cn/doc/spark/Web.html) + + [x] [智谱 ChatGLM 系列模型](https://bigmodel.cn) + + [x] [360 智脑](https://ai.360.cn) + + [x] [腾讯混元大模型](https://cloud.tencent.com/document/product/1729) +2. 支持配置镜像以及众多第三方代理服务: + + [x] [OpenAI-SB](https://openai-sb.com) + + [x] [CloseAI](https://referer.shadowai.xyz/r/2412) + [x] [API2D](https://api2d.com/r/197971) + [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf) + [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (邀请码:`OneAPI`) - + [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 代理 -2. 支持通过**负载均衡**的方式访问多个渠道。 -3. 支持 **stream 模式**,可以通过流式传输实现打字机效果。 -4. 支持**多机部署**,[详见此处](#多机部署)。 -5. 支持**令牌管理**,设置令牌的过期时间和使用次数。 -6. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。 -7. 支持**通道管理**,批量创建通道。 -8. 支持发布公告,设置充值链接,设置新用户初始额度。 -9. 支持丰富的**自定义**设置, - 1. 支持自定义系统名称,logo 以及页脚。 - 2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。 -10. 支持通过系统访问令牌访问管理 API。 -11. 支持用户管理,支持**多种用户登录注册方式**: - + 邮箱登录注册以及通过邮箱进行密码重置。 + + [x] 自定义渠道:例如各种未收录的第三方代理服务 +3. 支持通过**负载均衡**的方式访问多个渠道。 +4. 支持 **stream 模式**,可以通过流式传输实现打字机效果。 +5. 支持**多机部署**,[详见此处](#多机部署)。 +6. 支持**令牌管理**,设置令牌的过期时间和额度。 +7. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。 +8. 支持**通道管理**,批量创建通道。 +9. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。 +10. 支持渠道**设置模型列表**。 +11. 支持**查看额度明细**。 +12. 支持**用户邀请奖励**。 +13. 支持以美元为单位显示额度。 +14. 支持发布公告,设置充值链接,设置新用户初始额度。 +15. 支持模型映射,重定向用户的请求模型。 +16. 支持失败自动重试。 +17. 支持绘图接口。 +18. 支持 [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/providers/openai/),渠道设置的代理部分填写 `https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/openai` 即可。 +19. 支持丰富的**自定义**设置, + 1. 支持自定义系统名称,logo 以及页脚。 + 2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。 +20. 支持通过系统访问令牌访问管理 API。 +21. 支持 Cloudflare Turnstile 用户校验。 +22. 支持用户管理,支持**多种用户登录注册方式**: + + 邮箱登录注册(支持注册邮箱白名单)以及通过邮箱进行密码重置。 + [GitHub 开放授权](https://github.com/settings/applications/new)。 + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 -12. 未来其他大模型开放 API 后,将第一时间支持,并将其封装成同样的 API 访问方式。 ## 部署 ### 基于 Docker 进行部署 -执行:`docker run -d --restart always -p 3000:3000 -v /home/ubuntu/data/one-api:/data justsong/one-api` +```shell +# 使用 SQLite 的部署命令: +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 +# 使用 MySQL 的部署命令,在上面的基础上添加 `-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi"`,请自行修改数据库连接参数,不清楚如何修改请参见下面环境变量一节。 +# 例如: +docker run --name one-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api +``` -`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。 +其中,`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。 -数据将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录,请确保该目录存在且具有写入权限,或者更改为合适的目录。 +数据和日志将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录,请确保该目录存在且具有写入权限,或者更改为合适的目录。 + +如果启动失败,请添加 `--privileged=true`,具体参考 https://github.com/songquanpeng/one-api/issues/482 。 + +如果上面的镜像无法拉取,可以尝试使用 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` Nginx 的参考配置: ``` @@ -95,6 +141,7 @@ server{ proxy_set_header X-Forwarded-For $remote_addr; proxy_cache_bypass $http_upgrade; proxy_set_header Accept-Encoding gzip; + proxy_read_timeout 300s; # GPT-4 需要较长的超时时间,请自行调整 } } ``` @@ -111,6 +158,21 @@ sudo certbot --nginx sudo service nginx restart ``` +初始账号用户名为 `root`,密码为 `123456`。 + + +### 基于 Docker Compose 进行部署 + +> 仅启动方式不同,参数设置不变,请参考基于 Docker 部署部分 + +```shell +# 目前支持 MySQL 启动,数据存储在 ./data/mysql 文件夹内 +docker-compose up -d + +# 查看部署状态 +docker-compose ps +``` + ### 手动部署 1. 从 [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) 下载可执行文件或者从源码编译: ```shell @@ -120,7 +182,7 @@ sudo service nginx restart cd one-api/web npm install npm run build - + # 构建后端 cd .. go mod download @@ -137,12 +199,95 @@ 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` 后服务器将定期从数据库同步配置,在使用远程数据库的情况下,推荐设置该项并启用 Redis,无论主从。 +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 +``` + +注意修改端口号,之后在页面上设置接口地址(例如:https://openai.justsong.cn/ )和 API Key 即可。 + +#### 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`。 + +#### QChatGPT - QQ机器人 +项目主页:https://github.com/RockChinQ/QChatGPT + +根据文档完成部署后,在`config.py`设置配置项`openai_config`的`reverse_proxy`为 One API 后端地址,设置`api_key`为 One API 生成的key,并在配置项`completion_api_params`的`model`参数设置为 One API 支持的模型名称。 + +可安装 [Switcher 插件](https://github.com/RockChinQ/Switcher)在运行时切换所使用的模型。 + +### 部署到第三方平台 +
+部署到 Sealos +
+ +> Sealos 的服务器在国外,不需要额外处理网络问题,支持高并发 & 动态伸缩。 + +点击以下按钮一键部署(部署后访问出现 404 请等待 3~5 分钟): + +[![Deploy-on-Sealos.svg](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api) + +
+
+ +
+部署到 Zeabur +
+ +> Zeabur 的服务器在国外,自动解决了网络的问题,同时免费的额度也足够个人使用 + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/7Q0KO3) + +1. 首先 fork 一份代码。 +2. 进入 [Zeabur](https://zeabur.com?referralCode=songquanpeng),登录,进入控制台。 +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`,值为 `:@tcp(:)/one-api` ,然后保存。 注意如果不填写 `SQL_DSN`,数据将无法持久化,重新部署后数据会丢失。 +7. 选择 Redeploy。 +8. 进入下方 Domains,选择一个合适的域名前缀,如 "my-one-api",最终域名为 "my-one-api.zeabur.app",也可以 CNAME 自己的域名。 +9. 等待部署完成,点击生成的域名进入 One API。 + +
+
+ +
+部署到 Render +
+ +> Render 提供免费额度,绑卡后可以进一步提升额度 + +Render 可以直接部署 docker 镜像,不需要 fork 仓库:https://dashboard.render.com + +
+
+ ## 配置 系统本身开箱即用。 @@ -150,32 +295,87 @@ sudo service nginx restart 等到系统启动后,使用 `root` 用户登录系统并做进一步的配置。 -## 使用方式 -在`渠道`页面中添加你的 API Key,之后在`令牌`页面中新增一个访问令牌。 +**Note**:如果你不知道某个配置项的含义,可以临时删掉值以看到进一步的提示文字。 + +## 使用方法 +在`渠道`页面中添加你的 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 的格式取决于你所使用的客户端。 + +例如对于 OpenAI 的官方库: +```bash +OPENAI_API_KEY="sk-xxxxxx" +OPENAI_API_BASE="https://:/v1" +``` + +```mermaid +graph LR + A(用户) + A --->|使用 One API 分发的 key 进行请求| B(One API) + B -->|中继请求| C(OpenAI) + B -->|中继请求| D(Azure) + B -->|中继请求| E(其他 OpenAI API 格式下游渠道) + B -->|中继并修改请求体和返回体| F(非 OpenAI API 格式下游渠道) +``` + 可以通过在令牌后面添加渠道 ID 的方式指定使用哪一个渠道处理本次请求,例如:`Authorization: Bearer ONE_API_KEY-CHANNEL_ID`。 注意,需要是管理员用户创建的令牌才能指定渠道 ID。 不加的话将会使用负载均衡的方式使用多个渠道。 ### 环境变量 -1. `REDIS_CONN_STRING`:设置之后将使用 Redis 作为请求频率限制的存储,而非使用内存存储。 +1. `REDIS_CONN_STRING`:设置之后将使用 Redis 作为缓存使用。 + 例子:`REDIS_CONN_STRING=redis://default:redispw@localhost:49153` + + 如果数据库访问延迟很低,没有必要启用 Redis,启用后反而会出现数据滞后的问题。 2. `SESSION_SECRET`:设置之后将使用固定的会话密钥,这样系统重新启动后已登录用户的 cookie 将依旧有效。 + 例子:`SESSION_SECRET=random_string` -3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite。 - + 例子:`SQL_DSN=root:123456@tcp(localhost:3306)/one-api` -4. `FRONTEND_BASE_URL`:设置之后将使用指定的前端地址,而非后端地址。 +3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite,请使用 MySQL 或 PostgreSQL。 + + 例子: + + MySQL:`SQL_DSN=root:123456@tcp(localhost:3306)/oneapi` + + PostgreSQL:`SQL_DSN=postgres://postgres:123456@localhost:5432/oneapi`(适配中,欢迎反馈) + + 注意需要提前建立数据库 `oneapi`,无需手动建表,程序将自动建表。 + + 如果使用本地数据库:部署命令可添加 `--network="host"` 以使得容器内的程序可以访问到宿主机上的 MySQL。 + + 如果使用云数据库:如果云服务器需要验证身份,需要在连接参数中添加 `?tls=skip-verify`。 + + 请根据你的数据库配置修改下列参数(或者保持默认值): + + `SQL_MAX_IDLE_CONNS`:最大空闲连接数,默认为 `100`。 + + `SQL_MAX_OPEN_CONNS`:最大打开连接数,默认为 `1000`。 + + 如果报错 `Error 1040: Too many connections`,请适当减小该值。 + + `SQL_CONN_MAX_LIFETIME`:连接的最大生命周期,默认为 `60`,单位分钟。 +4. `FRONTEND_BASE_URL`:设置之后将重定向页面请求到指定的地址,仅限从服务器设置。 + 例子:`FRONTEND_BASE_URL=https://openai.justsong.cn` -5. `SYNC_FREQUENCY`:设置之后将定期与数据库同步配置,单位为秒,未设置则不进行同步。 +5. `MEMORY_CACHE_ENABLED`:启用内存缓存,会导致用户额度的更新存在一定的延迟,可选值为 `true` 和 `false`,未设置则默认为 `false`。 + + 例子:`MEMORY_CACHE_ENABLED=true` +6. `SYNC_FREQUENCY`:在启用缓存的情况下与数据库同步配置的频率,单位为秒,默认为 `600` 秒。 + 例子:`SYNC_FREQUENCY=60` +7. `NODE_TYPE`:设置之后将指定节点类型,可选值为 `master` 和 `slave`,未设置则默认为 `master`。 + + 例子:`NODE_TYPE=slave` +8. `CHANNEL_UPDATE_FREQUENCY`:设置之后将定期更新渠道余额,单位为分钟,未设置则不进行更新。 + + 例子:`CHANNEL_UPDATE_FREQUENCY=1440` +9. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。 + + 例子:`CHANNEL_TEST_FREQUENCY=1440` +10. `POLLING_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。 + + 例子:`POLLING_INTERVAL=5` +11. `BATCH_UPDATE_ENABLED`:启用数据库批量更新聚合,会导致用户额度的更新存在一定的延迟可选值为 `true` 和 `false`,未设置则默认为 `false`。 + + 例子:`BATCH_UPDATE_ENABLED=true` + + 如果你遇到了数据库连接数过多的问题,可以尝试启用该选项。 +12. `BATCH_UPDATE_INTERVAL=5`:批量更新聚合的时间间隔,单位为秒,默认为 `5`。 + + 例子:`BATCH_UPDATE_INTERVAL=5` +13. 请求频率限制: + + `GLOBAL_API_RATE_LIMIT`:全局 API 速率限制(除中继请求外),单 ip 三分钟内的最大请求数,默认为 `180`。 + + `GLOBAL_WEB_RATE_LIMIT`:全局 Web 速率限制,单 ip 三分钟内的最大请求数,默认为 `60`。 +14. 编码器缓存设置: + + `TIKTOKEN_CACHE_DIR`:默认程序启动时会联网下载一些通用的词元的编码,如:`gpt-3.5-turbo`,在一些网络环境不稳定,或者离线情况,可能会导致启动有问题,可以配置此目录缓存数据,可迁移到离线环境。 + + `DATA_GYM_CACHE_DIR`:目前该配置作用与 `TIKTOKEN_CACHE_DIR` 一致,但是优先级没有它高。 +15. `RELAY_TIMEOUT`:中继超时设置,单位为秒,默认不设置超时时间。 ### 命令行参数 1. `--port `: 指定服务器监听的端口号,默认为 `3000`。 + 例子:`--port 3000` -2. `--log-dir `: 指定日志文件夹,如果没有设置,日志将不会被保存。 +2. `--log-dir `: 指定日志文件夹,如果没有设置,默认保存至工作目录的 `logs` 文件夹下。 + 例子:`--log-dir ./logs` 3. `--version`: 打印系统版本号并退出。 4. `--help`: 查看命令的使用帮助和参数说明。 @@ -190,8 +390,41 @@ 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.33,GPT4 为 2,与官方保持一致。 + + 如果是非流模式,官方接口会返回消耗的总 token,但是你要注意提示和补全的消耗倍率不一样。 + + 注意,One API 的默认倍率就是官方倍率,是已经调整过的。 +2. 账户额度足够为什么提示额度不足? + 请检查你的令牌额度是否足够,这个和账户额度是分开的。 + 令牌额度仅供用户设置最大使用量,用户可自由设置。 -2. 宝塔部署后访问出现空白页面? - + 自动配置的问题,详见[#97](https://github.com/songquanpeng/one-api/issues/97)。 \ No newline at end of file +3. 提示无可用渠道? + + 请检查的用户分组和渠道分组设置。 + + 以及渠道的模型设置。 +4. 渠道测试报错:`invalid character '<' looking for beginning of value` + + 这是因为返回值不是合法的 JSON,而是一个 HTML 页面。 + + 大概率是你的部署站的 IP 或代理的节点被 CloudFlare 封禁了。 +5. ChatGPT Next Web 报错:`Failed to fetch` + + 部署的时候不要设置 `BASE_URL`。 + + 检查你的接口地址和 API Key 有没有填对。 + + 检查是否启用了 HTTPS,浏览器会拦截 HTTPS 域名下的 HTTP 请求。 +6. 报错:`当前分组负载已饱和,请稍后再试` + + 上游通道 429 了。 +7. 升级之后我的数据会丢失吗? + + 如果使用 MySQL,不会。 + + 如果使用 SQLite,需要按照我所给的部署命令挂载 volume 持久化 one-api.db 数据库文件,否则容器重启后数据会丢失。 +8. 升级之前数据库需要做变更吗? + + 一般情况下不需要,系统将在初始化的时候自动调整。 + + 如果需要的话,我会在更新日志中说明,并给出脚本。 + +## 相关项目 +* [FastGPT](https://github.com/labring/FastGPT): 基于 LLM 大语言模型的知识库问答系统 +* [ChatGPT Next Web](https://github.com/Yidadaa/ChatGPT-Next-Web): 一键拥有你自己的跨平台 ChatGPT 应用 + +## 注意 + +本项目使用 MIT 协议进行开源,**在此基础上**,必须在页面底部保留署名以及指向本项目的链接。如果不想保留署名,必须首先获得授权。 + +同样适用于基于本项目的二开项目。 + +依据 MIT 协议,使用者需自行承担使用本项目的风险与责任,本开源项目开发者与此无关。 diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..e69de29b diff --git a/bin/migration_v0.3-v0.4.sql b/bin/migration_v0.3-v0.4.sql new file mode 100644 index 00000000..e6103c29 --- /dev/null +++ b/bin/migration_v0.3-v0.4.sql @@ -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 +); diff --git a/bin/time_test.sh b/bin/time_test.sh new file mode 100644 index 00000000..2cde4a65 --- /dev/null +++ b/bin/time_test.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +if [ $# -lt 3 ]; then + echo "Usage: time_test.sh []" + exit 1 +fi + +domain=$1 +key=$2 +count=$3 +model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo + +total_time=0 +times=() + +for ((i=1; i<=count; i++)); do + result=$(curl -o /dev/null -s -w "%{http_code} %{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": "'"$model"'", "stream": false, "max_tokens": 1}') + http_code=$(echo "$result" | awk '{print $1}') + time=$(echo "$result" | awk '{print $2}') + echo "HTTP status code: $http_code, Time taken: $time" + total_time=$(bc <<< "$total_time + $time") + times+=("$time") +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" diff --git a/common/constants.go b/common/constants.go index 7c1ff298..c7d3f222 100644 --- a/common/constants.go +++ b/common/constants.go @@ -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,18 +16,20 @@ var ServerAddress = "http://localhost:3000" var Footer = "" var Logo = "" var TopUpLink = "" - -var UsingSQLite = false +var ChatLink = "" +var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens +var DisplayInCurrencyEnabled = true +var DisplayTokenStatEnabled = true // Any options with "Secret", "Token" in its key won't be return by GetOptions var SessionSecret = uuid.New().String() -var SQLitePath = "one-api.db" var OptionMap map[string]string var OptionMapRWMutex sync.RWMutex var ItemsPerPage = 10 +var MaxRecentItems = 100 var PasswordLoginEnabled = true var PasswordRegisterEnabled = true @@ -34,6 +39,24 @@ var WeChatAuthEnabled = false var TurnstileCheckEnabled = false var RegisterEnabled = true +var EmailDomainRestrictionEnabled = false +var EmailDomainWhitelist = []string{ + "gmail.com", + "163.com", + "126.com", + "qq.com", + "outlook.com", + "hotmail.com", + "icloud.com", + "yahoo.com", + "foxmail.com", +} + +var DebugEnabled = os.Getenv("DEBUG") == "true" +var MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true" + +var LogConsumeEnabled = true + var SMTPServer = "" var SMTPPort = 587 var SMTPAccount = "" @@ -51,13 +74,33 @@ var TurnstileSiteKey = "" var TurnstileSecretKey = "" var QuotaForNewUser = 0 +var QuotaForInviter = 0 +var QuotaForInvitee = 0 var ChannelDisableThreshold = 5.0 var AutomaticDisableChannelEnabled = false var QuotaRemindThreshold = 1000 var PreConsumedQuota = 500 +var ApproximateTokenEnabled = false +var RetryTimes = 0 var RootUserEmail = "" +var IsMasterNode = os.Getenv("NODE_TYPE") != "slave" + +var requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) +var RequestInterval = time.Duration(requestInterval) * time.Second + +var SyncFrequency = GetOrDefault("SYNC_FREQUENCY", 10*60) // unit is second + +var BatchUpdateEnabled = false +var BatchUpdateInterval = GetOrDefault("BATCH_UPDATE_INTERVAL", 5) + +var RelayTimeout = GetOrDefault("RELAY_TIMEOUT", 0) // unit is second + +const ( + RequestIdKey = "X-Oneapi-Request-Id" +) + const ( RoleGuestUser = 0 RoleCommonUser = 1 @@ -75,10 +118,10 @@ var ( // All duration's unit is seconds // Shouldn't larger then RateLimitKeyExpirationDuration var ( - GlobalApiRateLimitNum = 180 + GlobalApiRateLimitNum = GetOrDefault("GLOBAL_API_RATE_LIMIT", 180) GlobalApiRateLimitDuration int64 = 3 * 60 - GlobalWebRateLimitNum = 60 + GlobalWebRateLimitNum = GetOrDefault("GLOBAL_WEB_RATE_LIMIT", 60) GlobalWebRateLimitDuration int64 = 3 * 60 UploadRateLimitNum = 10 @@ -112,37 +155,62 @@ const ( ) const ( - ChannelStatusUnknown = 0 - ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! - ChannelStatusDisabled = 2 // also don't use 0 + ChannelStatusUnknown = 0 + ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! + ChannelStatusManuallyDisabled = 2 // also don't use 0 + ChannelStatusAutoDisabled = 3 ) const ( - ChannelTypeUnknown = 0 - ChannelTypeOpenAI = 1 - ChannelTypeAPI2D = 2 - ChannelTypeAzure = 3 - ChannelTypeCloseAI = 4 - ChannelTypeOpenAISB = 5 - ChannelTypeOpenAIMax = 6 - ChannelTypeOhMyGPT = 7 - ChannelTypeCustom = 8 - ChannelTypeAILS = 9 - ChannelTypeAIProxy = 10 - ChannelTypePaLM = 11 + ChannelTypeUnknown = 0 + ChannelTypeOpenAI = 1 + ChannelTypeAPI2D = 2 + ChannelTypeAzure = 3 + ChannelTypeCloseAI = 4 + ChannelTypeOpenAISB = 5 + ChannelTypeOpenAIMax = 6 + ChannelTypeOhMyGPT = 7 + ChannelTypeCustom = 8 + ChannelTypeAILS = 9 + ChannelTypeAIProxy = 10 + ChannelTypePaLM = 11 + ChannelTypeAPI2GPT = 12 + ChannelTypeAIGC2D = 13 + ChannelTypeAnthropic = 14 + ChannelTypeBaidu = 15 + ChannelTypeZhipu = 16 + ChannelTypeAli = 17 + ChannelTypeXunfei = 18 + ChannelType360 = 19 + ChannelTypeOpenRouter = 20 + ChannelTypeAIProxyLibrary = 21 + ChannelTypeFastGPT = 22 + ChannelTypeTencent = 23 ) 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.closeai-proxy.xyz", // 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 + "https://api.aigc2d.com", // 13 + "https://api.anthropic.com", // 14 + "https://aip.baidubce.com", // 15 + "https://open.bigmodel.cn", // 16 + "https://dashscope.aliyuncs.com", // 17 + "", // 18 + "https://ai.360.cn", // 19 + "https://openrouter.ai/api", // 20 + "https://api.aiproxy.io", // 21 + "https://fastgpt.run/api/openapi", // 22 + "https://hunyuan.cloud.tencent.com", //23 } diff --git a/common/database.go b/common/database.go new file mode 100644 index 00000000..c7e9fd52 --- /dev/null +++ b/common/database.go @@ -0,0 +1,6 @@ +package common + +var UsingSQLite = false +var UsingPostgreSQL = false + +var SQLitePath = "one-api.db" diff --git a/common/gin.go b/common/gin.go new file mode 100644 index 00000000..ffa1e218 --- /dev/null +++ b/common/gin.go @@ -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 +} diff --git a/common/group-ratio.go b/common/group-ratio.go new file mode 100644 index 00000000..1ec73c78 --- /dev/null +++ b/common/group-ratio.go @@ -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 +} diff --git a/common/init.go b/common/init.go index 0f22c69b..1e9c85ce 100644 --- a/common/init.go +++ b/common/init.go @@ -12,7 +12,7 @@ var ( Port = flag.Int("port", 3000, "the listening port") PrintVersion = flag.Bool("version", false, "print version and exit") PrintHelp = flag.Bool("help", false, "print help and exit") - LogDir = flag.String("log-dir", "", "specify the log directory") + LogDir = flag.String("log-dir", "./logs", "specify the log directory") ) func printHelp() { diff --git a/common/logger.go b/common/logger.go index 0b8b2cfb..61627217 100644 --- a/common/logger.go +++ b/common/logger.go @@ -1,29 +1,47 @@ package common import ( + "context" "fmt" "github.com/gin-gonic/gin" "io" "log" "os" "path/filepath" + "sync" "time" ) -func SetupGinLog() { +const ( + loggerINFO = "INFO" + loggerWarn = "WARN" + loggerError = "ERR" +) + +const maxLogCount = 1000000 + +var logCount int +var setupLogLock sync.Mutex +var setupLogWorking bool + +func SetupLogger() { if *LogDir != "" { - commonLogPath := filepath.Join(*LogDir, "common.log") - errorLogPath := filepath.Join(*LogDir, "error.log") - commonFd, err := os.OpenFile(commonLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + ok := setupLogLock.TryLock() + if !ok { + log.Println("setup log is already working") + return + } + defer func() { + setupLogLock.Unlock() + setupLogWorking = false + }() + logPath := filepath.Join(*LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102"))) + fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Fatal("failed to open log file") } - errorFd, err := os.OpenFile(errorLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - log.Fatal("failed to open log file") - } - gin.DefaultWriter = io.MultiWriter(os.Stdout, commonFd) - gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, errorFd) + gin.DefaultWriter = io.MultiWriter(os.Stdout, fd) + gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd) } } @@ -37,8 +55,46 @@ func SysError(s string) { _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) } +func LogInfo(ctx context.Context, msg string) { + logHelper(ctx, loggerINFO, msg) +} + +func LogWarn(ctx context.Context, msg string) { + logHelper(ctx, loggerWarn, msg) +} + +func LogError(ctx context.Context, msg string) { + logHelper(ctx, loggerError, msg) +} + +func logHelper(ctx context.Context, level string, msg string) { + writer := gin.DefaultErrorWriter + if level == loggerINFO { + writer = gin.DefaultWriter + } + id := ctx.Value(RequestIdKey) + now := time.Now() + _, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg) + logCount++ // we don't need accurate count, so no lock here + if logCount > maxLogCount && !setupLogWorking { + logCount = 0 + setupLogWorking = true + go func() { + SetupLogger() + }() + } +} + func FatalLog(v ...any) { t := time.Now() _, _ = 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) + } +} diff --git a/common/model-ratio.go b/common/model-ratio.go index cee4559c..681f0ae7 100644 --- a/common/model-ratio.go +++ b/common/model-ratio.go @@ -1,52 +1,122 @@ package common -import "encoding/json" +import ( + "encoding/json" + "strings" + "time" +) +// ModelRatio // https://platform.openai.com/docs/models/model-endpoint-compatibility +// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf // https://openai.com/pricing // TODO: when a new api is enabled, check the pricing here +// 1 === $0.002 / 1K tokens +// 1 === ¥0.014 / 1k tokens var ModelRatio = map[string]float64{ - "gpt-4": 15, - "gpt-4-0314": 15, - "gpt-4-32k": 30, - "gpt-4-32k-0314": 30, - "gpt-3.5-turbo": 1, - "gpt-3.5-turbo-0301": 1, - "text-ada-001": 0.2, - "text-babbage-001": 0.25, - "text-curie-001": 1, - "text-davinci-002": 10, - "text-davinci-003": 10, - "text-davinci-edit-001": 10, - "code-davinci-edit-001": 10, - "whisper-1": 10, - "davinci": 10, - "curie": 10, - "babbage": 10, - "ada": 10, - "text-embedding-ada-002": 0.2, - "text-search-ada-doc-001": 10, - "text-moderation-stable": 10, - "text-moderation-latest": 10, + "gpt-4": 15, + "gpt-4-0314": 15, + "gpt-4-0613": 15, + "gpt-4-32k": 30, + "gpt-4-32k-0314": 30, + "gpt-4-32k-0613": 30, + "gpt-4-1106-preview": 5, // $0.01 / 1K tokens + "gpt-4-vision-preview": 5, // $0.01 / 1K tokens + "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, + "gpt-3.5-turbo-instruct": 0.75, // $0.0015 / 1K tokens + "gpt-3.5-turbo-1106": 0.5, // $0.001 / 1K tokens + "text-ada-001": 0.2, + "text-babbage-001": 0.25, + "text-curie-001": 1, + "text-davinci-002": 10, + "text-davinci-003": 10, + "text-davinci-edit-001": 10, + "code-davinci-edit-001": 10, + "whisper-1": 15, // $0.006 / minute -> $0.006 / 150 words -> $0.006 / 200 tokens -> $0.03 / 1k tokens + "davinci": 10, + "curie": 10, + "babbage": 10, + "ada": 10, + "text-embedding-ada-002": 0.05, + "text-search-ada-doc-001": 10, + "text-moderation-stable": 0.1, + "text-moderation-latest": 0.1, + "dall-e": 8, + "claude-instant-1": 0.815, // $1.63 / 1M tokens + "claude-2": 5.51, // $11.02 / 1M tokens + "ERNIE-Bot": 0.8572, // ¥0.012 / 1k tokens + "ERNIE-Bot-turbo": 0.5715, // ¥0.008 / 1k tokens + "ERNIE-Bot-4": 8.572, // ¥0.12 / 1k tokens + "Embedding-V1": 0.1429, // ¥0.002 / 1k tokens + "PaLM-2": 1, + "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens + "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens + "chatglm_std": 0.3572, // ¥0.005 / 1k tokens + "chatglm_lite": 0.1429, // ¥0.002 / 1k tokens + "qwen-turbo": 0.8572, // ¥0.012 / 1k tokens + "qwen-plus": 10, // ¥0.14 / 1k tokens + "text-embedding-v1": 0.05, // ¥0.0007 / 1k tokens + "SparkDesk": 1.2858, // ¥0.018 / 1k tokens + "360GPT_S2_V9": 0.8572, // ¥0.012 / 1k tokens + "embedding-bert-512-v1": 0.0715, // ¥0.001 / 1k tokens + "embedding_s1_v1": 0.0715, // ¥0.001 / 1k tokens + "semantic_similarity_s1_v1": 0.0715, // ¥0.001 / 1k tokens + "hunyuan": 7.143, // ¥0.1 / 1k tokens // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0 } 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 } + +func GetCompletionRatio(name string) float64 { + if strings.HasPrefix(name, "gpt-3.5") { + if strings.HasSuffix(name, "1106") { + return 2 + } + if name == "gpt-3.5-turbo" || name == "gpt-3.5-turbo-16k" { + // TODO: clear this after 2023-12-11 + now := time.Now() + // https://platform.openai.com/docs/models/continuous-model-upgrades + // if after 2023-12-11, use 2 + if now.After(time.Date(2023, 12, 11, 0, 0, 0, 0, time.UTC)) { + return 2 + } + } + return 1.333333 + } + if strings.HasPrefix(name, "gpt-4") { + if strings.HasSuffix(name, "preview") { + return 3 + } + return 2 + } + if strings.HasPrefix(name, "claude-instant-1") { + return 3.38 + } + if strings.HasPrefix(name, "claude-2") { + return 2.965517 + } + return 1 +} diff --git a/common/redis.go b/common/redis.go index 56db2b40..12c477b8 100644 --- a/common/redis.go +++ b/common/redis.go @@ -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,36 @@ 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() +} + +func RedisDecrease(key string, value int64) error { + ctx := context.Background() + return RDB.DecrBy(ctx, key, value).Err() +} diff --git a/common/utils.go b/common/utils.go index 4892404c..21bec8f5 100644 --- a/common/utils.go +++ b/common/utils.go @@ -5,7 +5,9 @@ import ( "github.com/google/uuid" "html/template" "log" + "math/rand" "net" + "os" "os/exec" "runtime" "strconv" @@ -133,10 +135,47 @@ 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() } +func GetTimeString() string { + now := time.Now() + return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) +} + func Max(a int, b int) int { if a >= b { return a @@ -144,3 +183,27 @@ func Max(a int, b int) int { return b } } + +func GetOrDefault(env string, defaultValue int) int { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + num, err := strconv.Atoi(os.Getenv(env)) + if err != nil { + SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue)) + return defaultValue + } + return num +} + +func MessageWithRequestId(message string, id string) string { + return fmt.Sprintf("%s (request id: %s)", message, id) +} + +func String2Int(str string) int { + num, err := strconv.Atoi(str) + if err != nil { + return 0 + } + return num +} diff --git a/controller/billing.go b/controller/billing.go index 2f0d90fe..42e86aea 100644 --- a/controller/billing.go +++ b/controller/billing.go @@ -2,12 +2,72 @@ 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 remainQuota int + var usedQuota int + var err error + var token *model.Token + var expiredTime int64 + if common.DisplayTokenStatEnabled { + tokenId := c.GetInt("token_id") + token, err = model.GetTokenById(tokenId) + expiredTime = token.ExpiredTime + remainQuota = token.RemainQuota + usedQuota = token.UsedQuota + } else { + userId := c.GetInt("id") + remainQuota, err = model.GetUserQuota(userId) + usedQuota, err = model.GetUserUsedQuota(userId) + } + if expiredTime <= 0 { + expiredTime = 0 + } + if err != nil { + openAIError := OpenAIError{ + Message: err.Error(), + Type: "upstream_error", + } + c.JSON(200, gin.H{ + "error": openAIError, + }) + return + } + quota := remainQuota + usedQuota + amount := float64(quota) + if common.DisplayInCurrencyEnabled { + amount /= common.QuotaPerUnit + } + if token != nil && token.UnlimitedQuota { + amount = 100000000 + } + subscription := OpenAISubscriptionResponse{ + Object: "billing_subscription", + HasPaymentMethod: true, + SoftLimitUSD: amount, + HardLimitUSD: amount, + SystemHardLimitUSD: amount, + AccessUntil: expiredTime, + } + c.JSON(200, subscription) + return +} + +func GetUsage(c *gin.Context) { + 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(), @@ -18,23 +78,13 @@ func GetSubscription(c *gin.Context) { }) return } - subscription := OpenAISubscriptionResponse{ - Object: "billing_subscription", - HasPaymentMethod: true, - SoftLimitUSD: float64(quota), - HardLimitUSD: float64(quota), - SystemHardLimitUSD: float64(quota), + amount := float64(quota) + if common.DisplayInCurrencyEnabled { + amount /= common.QuotaPerUnit } - c.JSON(200, subscription) - return -} - -func GetUsage(c *gin.Context) { - //userId := c.GetInt("id") - // TODO: get usage from database usage := OpenAIUsageResponse{ Object: "list", - TotalUsage: 0, + TotalUsage: amount * 100, } c.JSON(200, usage) return diff --git a/controller/channel-billing.go b/controller/channel-billing.go index e135e5fc..6ddad7ea 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -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 @@ -21,6 +22,7 @@ type OpenAISubscriptionResponse struct { SoftLimitUSD float64 `json:"soft_limit_usd"` HardLimitUSD float64 `json:"hard_limit_usd"` SystemHardLimitUSD float64 `json:"system_hard_limit_usd"` + AccessUntil int64 `json:"access_until"` } type OpenAIUsageDailyCost struct { @@ -31,38 +33,202 @@ 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 { Object string `json:"object"` //DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"` TotalUsage float64 `json:"total_usage"` // unit: 0.01 dollar } -func updateChannelBalance(channel *model.Channel) (float64, error) { - baseURL := common.ChannelBaseURLs[channel.Type] - switch channel.Type { - case common.ChannelTypeAzure: - return 0, errors.New("尚未实现") - case common.ChannelTypeCustom: - baseURL = channel.BaseURL - } - url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL) +type OpenAISBUsageResponse struct { + Msg string `json:"msg"` + Data *struct { + Credit string `json:"credit"` + } `json:"data"` +} - client := &http.Client{} - req, err := http.NewRequest("GET", url, nil) +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"` +} + +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 +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) { + req, err := http.NewRequest(method, url, nil) if err != nil { - return 0, err + return nil, err } - auth := fmt.Sprintf("Bearer %s", channel.Key) - req.Header.Add("Authorization", auth) - res, err := client.Do(req) + for k := range headers { + req.Header.Add(k, headers.Get(k)) + } + res, err := httpClient.Do(req) if err != nil { - return 0, err + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code: %d", res.StatusCode) } body, err := io.ReadAll(res.Body) if err != nil { - return 0, err + return nil, err } err = res.Body.Close() + if err != nil { + return nil, err + } + return body, nil +} + +func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) { + url := fmt.Sprintf("%s/dashboard/billing/credit_grants", channel.GetBaseURL()) + 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) { + 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 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) { + baseURL := common.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() == "" { + channel.BaseURL = &baseURL + } + switch channel.Type { + case common.ChannelTypeOpenAI: + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + case common.ChannelTypeAzure: + return 0, errors.New("尚未实现") + case common.ChannelTypeCustom: + baseURL = channel.GetBaseURL() + case common.ChannelTypeCloseAI: + return updateChannelCloseAIBalance(channel) + case common.ChannelTypeOpenAISB: + return updateChannelOpenAISBBalance(channel) + case common.ChannelTypeAIProxy: + return updateChannelAIProxyBalance(channel) + case common.ChannelTypeAPI2GPT: + return updateChannelAPI2GPTBalance(channel) + case common.ChannelTypeAIGC2D: + return updateChannelAIGC2DBalance(channel) + default: + return 0, errors.New("尚未实现") + } + url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL) + + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) if err != nil { return 0, err } @@ -73,22 +239,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 } @@ -157,6 +313,7 @@ func updateAllChannelsBalance() error { disableChannel(channel.Id, channel.Name, "余额不足") } } + time.Sleep(common.RequestInterval) } return nil } @@ -177,3 +334,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") + } +} diff --git a/controller/channel-test.go b/controller/channel-test.go index 0d32c8c6..3c6c8f43 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -10,34 +10,56 @@ import ( "one-api/common" "one-api/model" "strconv" + "strings" "sync" "time" ) -func testChannel(channel *model.Channel, request *ChatRequest) error { - if request.Model == "" { +func testChannel(channel *model.Channel, request ChatRequest) (err error, openaiErr *OpenAIError) { + switch channel.Type { + case common.ChannelTypePaLM: + fallthrough + case common.ChannelTypeAnthropic: + fallthrough + case common.ChannelTypeBaidu: + fallthrough + case common.ChannelTypeZhipu: + fallthrough + case common.ChannelTypeAli: + fallthrough + case common.ChannelType360: + fallthrough + case common.ChannelTypeXunfei: + return errors.New("该渠道类型当前版本不支持测试,请手动测试"), nil + case common.ChannelTypeAzure: + request.Model = "gpt-35-turbo" + defer func() { + if err != nil { + err = errors.New("请确保已在 Azure 上创建了 gpt-35-turbo 模型,并且 apiVersion 已正确填写!") + } + }() + default: 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) + requestURL = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=2023-03-15-preview", channel.GetBaseURL(), request.Model) } else { - if channel.Type == common.ChannelTypeCustom { - requestURL = channel.BaseURL + if channel.GetBaseURL() != "" { + requestURL = channel.GetBaseURL() } requestURL += "/v1/chat/completions" } + // for Cloudflare AI gateway: https://github.com/songquanpeng/one-api/pull/639 + requestURL = strings.Replace(requestURL, "/v1/v1", "/v1", 1) jsonData, err := json.Marshal(request) if err != nil { - return err + return err, nil } req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData)) if err != nil { - return err + return err, nil } if channel.Type == common.ChannelTypeAzure { req.Header.Set("api-key", channel.Key) @@ -45,27 +67,25 @@ func testChannel(channel *model.Channel, request *ChatRequest) error { req.Header.Set("Authorization", "Bearer "+channel.Key) } req.Header.Set("Content-Type", "application/json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { - return err + return err, nil } defer resp.Body.Close() var response TextResponse err = json.NewDecoder(resp.Body).Decode(&response) if err != nil { - return err + return err, nil } - 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)) + 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)), &response.Error } - return nil + return nil, nil } -func buildTestRequest(c *gin.Context) *ChatRequest { - model_ := c.Query("model") +func buildTestRequest() *ChatRequest { testRequest := &ChatRequest{ - Model: model_, + Model: "", // this will be set later MaxTokens: 1, } testMessage := Message{ @@ -93,9 +113,9 @@ func TestChannel(c *gin.Context) { }) return } - testRequest := buildTestRequest(c) + testRequest := buildTestRequest() tik := time.Now() - err = testChannel(channel, testRequest) + err, _ = testChannel(channel, *testRequest) tok := time.Now() milliseconds := tok.Sub(tik).Milliseconds() go channel.UpdateResponseTime(milliseconds) @@ -124,16 +144,19 @@ func disableChannel(channelId int, channelName string, reason string) { if common.RootUserEmail == "" { common.RootUserEmail = model.GetRootUserEmail() } - model.UpdateChannelStatusById(channelId, common.ChannelStatusDisabled) + model.UpdateChannelStatusById(channelId, common.ChannelStatusAutoDisabled) 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())) + common.SysError(fmt.Sprintf("failed to send email: %s", err.Error())) } } -func testAllChannels(c *gin.Context) error { +func testAllChannels(notify bool) error { + if common.RootUserEmail == "" { + common.RootUserEmail = model.GetRootUserEmail() + } testAllChannelsLock.Lock() if testAllChannelsRunning { testAllChannelsLock.Unlock() @@ -143,13 +166,9 @@ func testAllChannels(c *gin.Context) error { 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) + testRequest := buildTestRequest() var disableThreshold = int64(common.ChannelDisableThreshold * 1000) if disableThreshold == 0 { disableThreshold = 10000000 // a impossible value @@ -160,30 +179,34 @@ func testAllChannels(c *gin.Context) error { continue } tik := time.Now() - err := testChannel(channel, testRequest) + err, openaiErr := 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)) - } + 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()) + } + if shouldDisableChannel(openaiErr, -1) { 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())) + 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(c) + err := testAllChannels(true) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -197,3 +220,12 @@ func TestAllChannels(c *gin.Context) { }) 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") + } +} diff --git a/controller/channel.go b/controller/channel.go index 8afc0eed..904abc23 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -85,7 +85,7 @@ func AddChannel(c *gin.Context) { } channel.CreatedTime = common.GetTimestamp() keys := strings.Split(channel.Key, "\n") - channels := make([]model.Channel, 0) + channels := make([]model.Channel, 0, len(keys)) for _, key := range keys { if key == "" { continue @@ -127,6 +127,23 @@ func DeleteChannel(c *gin.Context) { return } +func DeleteDisabledChannel(c *gin.Context) { + rows, err := model.DeleteDisabledChannel() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": rows, + }) + return +} + func UpdateChannel(c *gin.Context) { channel := model.Channel{} err := c.ShouldBindJSON(&channel) diff --git a/controller/github.go b/controller/github.go index 93c2e8d3..ee995379 100644 --- a/controller/github.go +++ b/controller/github.go @@ -79,6 +79,14 @@ func getGitHubUserInfoByCode(code string) (*GitHubUser, error) { func GitHubOAuth(c *gin.Context) { session := sessions.Default(c) + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } username := session.Get("username") if username != nil { GitHubBind(c) @@ -125,7 +133,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(), @@ -205,3 +213,22 @@ func GitHubBind(c *gin.Context) { }) return } + +func GenerateOAuthCode(c *gin.Context) { + session := sessions.Default(c) + state := common.GetRandomString(12) + session.Set("oauth_state", state) + err := session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": state, + }) +} diff --git a/controller/group.go b/controller/group.go new file mode 100644 index 00000000..2b2f6006 --- /dev/null +++ b/controller/group.go @@ -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, + }) +} diff --git a/controller/log.go b/controller/log.go new file mode 100644 index 00000000..b65867fe --- /dev/null +++ b/controller/log.go @@ -0,0 +1,168 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "net/http" + "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")) + 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") + channel, _ := strconv.Atoi(c.Query("channel")) + logs, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, p*common.ItemsPerPage, common.ItemsPerPage, channel) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return +} + +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")) + 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 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return +} + +func SearchAllLogs(c *gin.Context) { + keyword := c.Query("keyword") + logs, err := model.SearchAllLogs(keyword) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return +} + +func SearchUserLogs(c *gin.Context) { + keyword := c.Query("keyword") + userId := c.GetInt("id") + logs, err := model.SearchUserLogs(userId, keyword) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return +} + +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") + channel, _ := strconv.Atoi(c.Query("channel")) + quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel) + //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "") + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": quotaNum, + //"token": tokenNum, + }, + }) + return +} + +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") + channel, _ := strconv.Atoi(c.Query("channel")) + quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel) + //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": quotaNum, + //"token": tokenNum, + }, + }) + return +} + +func DeleteHistoryLogs(c *gin.Context) { + targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64) + if targetTimestamp == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "target timestamp is required", + }) + return + } + count, err := model.DeleteOldLog(targetTimestamp) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": count, + }) + return +} diff --git a/controller/misc.go b/controller/misc.go index d200a239..2bcbb41f 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -3,10 +3,12 @@ package controller import ( "encoding/json" "fmt" - "github.com/gin-gonic/gin" "net/http" "one-api/common" "one-api/model" + "strings" + + "github.com/gin-gonic/gin" ) func GetStatus(c *gin.Context) { @@ -14,20 +16,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 @@ -75,6 +80,22 @@ func SendEmailVerification(c *gin.Context) { }) return } + if common.EmailDomainRestrictionEnabled { + allowed := false + for _, domain := range common.EmailDomainWhitelist { + if strings.HasSuffix(email, "@"+domain) { + allowed = true + break + } + } + if !allowed { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员启用了邮箱域名白名单,您的邮箱地址的域名不在白名单中", + }) + return + } + } if model.IsEmailAlreadyTaken(email) { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -124,8 +145,9 @@ func SendPasswordResetEmail(c *gin.Context) { link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code) subject := fmt.Sprintf("%s密码重置", common.SystemName) content := fmt.Sprintf("

您好,你正在进行%s密码重置。

"+ - "

点击此处进行密码重置。

"+ - "

重置链接 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, link, common.VerificationValidMinutes) + "

点击 此处 进行密码重置。

"+ + "

如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:
%s

"+ + "

重置链接 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, link, link, common.VerificationValidMinutes) err := common.SendEmail(subject, email, content) if err != nil { c.JSON(http.StatusOK, gin.H{ diff --git a/controller/model.go b/controller/model.go index 9825b4ab..7bd9d097 100644 --- a/controller/model.go +++ b/controller/model.go @@ -2,6 +2,7 @@ package controller import ( "fmt" + "github.com/gin-gonic/gin" ) @@ -23,20 +24,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,9 +51,27 @@ func init() { Organization: "*", Group: nil, IsBlocking: false, - } + }) // https://platform.openai.com/docs/models/model-endpoint-compatibility openAIModels = []OpenAIModels{ + { + Id: "dall-e", + Object: "model", + Created: 1677649963, + OwnedBy: "openai", + Permission: permission, + Root: "dall-e", + Parent: nil, + }, + { + Id: "whisper-1", + Object: "model", + Created: 1677649963, + OwnedBy: "openai", + Permission: permission, + Root: "whisper-1", + Parent: nil, + }, { Id: "gpt-3.5-turbo", Object: "model", @@ -70,6 +90,51 @@ 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-3.5-turbo-1106", + Object: "model", + Created: 1699593571, + OwnedBy: "openai", + Permission: permission, + Root: "gpt-3.5-turbo-1106", + Parent: nil, + }, + { + Id: "gpt-3.5-turbo-instruct", + Object: "model", + Created: 1677649963, + OwnedBy: "openai", + Permission: permission, + Root: "gpt-3.5-turbo-instruct", + Parent: nil, + }, { Id: "gpt-4", Object: "model", @@ -88,6 +153,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 +181,30 @@ 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, + }, + { + Id: "gpt-4-1106-preview", + Object: "model", + Created: 1699593571, + OwnedBy: "openai", + Permission: permission, + Root: "gpt-4-1106-preview", + Parent: nil, + }, + { + Id: "gpt-4-vision-preview", + Object: "model", + Created: 1699593571, + OwnedBy: "openai", + Permission: permission, + Root: "gpt-4-vision-preview", Parent: nil, }, { @@ -124,6 +216,267 @@ 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, + }, + { + 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, + }, + { + Id: "claude-instant-1", + Object: "model", + Created: 1677649963, + OwnedBy: "anthropic", + Permission: permission, + Root: "claude-instant-1", + Parent: nil, + }, + { + Id: "claude-2", + Object: "model", + Created: 1677649963, + OwnedBy: "anthropic", + Permission: permission, + Root: "claude-2", + Parent: nil, + }, + { + Id: "ERNIE-Bot", + Object: "model", + Created: 1677649963, + OwnedBy: "baidu", + Permission: permission, + Root: "ERNIE-Bot", + Parent: nil, + }, + { + Id: "ERNIE-Bot-turbo", + Object: "model", + Created: 1677649963, + OwnedBy: "baidu", + Permission: permission, + Root: "ERNIE-Bot-turbo", + Parent: nil, + }, + { + Id: "ERNIE-Bot-4", + Object: "model", + Created: 1677649963, + OwnedBy: "baidu", + Permission: permission, + Root: "ERNIE-Bot-4", + Parent: nil, + }, + { + Id: "Embedding-V1", + Object: "model", + Created: 1677649963, + OwnedBy: "baidu", + Permission: permission, + Root: "Embedding-V1", + Parent: nil, + }, + { + Id: "PaLM-2", + Object: "model", + Created: 1677649963, + OwnedBy: "google", + Permission: permission, + Root: "PaLM-2", + Parent: nil, + }, + { + Id: "chatglm_turbo", + Object: "model", + Created: 1677649963, + OwnedBy: "zhipu", + Permission: permission, + Root: "chatglm_turbo", + Parent: nil, + }, + { + Id: "chatglm_pro", + Object: "model", + Created: 1677649963, + OwnedBy: "zhipu", + Permission: permission, + Root: "chatglm_pro", + Parent: nil, + }, + { + Id: "chatglm_std", + Object: "model", + Created: 1677649963, + OwnedBy: "zhipu", + Permission: permission, + Root: "chatglm_std", + Parent: nil, + }, + { + Id: "chatglm_lite", + Object: "model", + Created: 1677649963, + OwnedBy: "zhipu", + Permission: permission, + Root: "chatglm_lite", + Parent: nil, + }, + { + Id: "qwen-turbo", + Object: "model", + Created: 1677649963, + OwnedBy: "ali", + Permission: permission, + Root: "qwen-turbo", + Parent: nil, + }, + { + Id: "qwen-plus", + Object: "model", + Created: 1677649963, + OwnedBy: "ali", + Permission: permission, + Root: "qwen-plus", + Parent: nil, + }, + { + Id: "text-embedding-v1", + Object: "model", + Created: 1677649963, + OwnedBy: "ali", + Permission: permission, + Root: "text-embedding-v1", + Parent: nil, + }, + { + Id: "SparkDesk", + Object: "model", + Created: 1677649963, + OwnedBy: "xunfei", + Permission: permission, + Root: "SparkDesk", + Parent: nil, + }, + { + Id: "360GPT_S2_V9", + Object: "model", + Created: 1677649963, + OwnedBy: "360", + Permission: permission, + Root: "360GPT_S2_V9", + Parent: nil, + }, + { + Id: "embedding-bert-512-v1", + Object: "model", + Created: 1677649963, + OwnedBy: "360", + Permission: permission, + Root: "embedding-bert-512-v1", + Parent: nil, + }, + { + Id: "embedding_s1_v1", + Object: "model", + Created: 1677649963, + OwnedBy: "360", + Permission: permission, + Root: "embedding_s1_v1", + Parent: nil, + }, + { + Id: "semantic_similarity_s1_v1", + Object: "model", + Created: 1677649963, + OwnedBy: "360", + Permission: permission, + Root: "semantic_similarity_s1_v1", + Parent: nil, + }, + { + Id: "hunyuan", + Object: "model", + Created: 1677649963, + OwnedBy: "tencent", + Permission: permission, + Root: "hunyuan", + Parent: nil, + }, } openAIModelsMap = make(map[string]OpenAIModels) for _, model := range openAIModels { @@ -132,7 +485,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) { diff --git a/controller/option.go b/controller/option.go index b5b675c6..bbf83578 100644 --- a/controller/option.go +++ b/controller/option.go @@ -2,18 +2,19 @@ package controller import ( "encoding/json" - "github.com/gin-gonic/gin" "net/http" "one-api/common" "one-api/model" "strings" + + "github.com/gin-gonic/gin" ) 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{ @@ -45,7 +46,15 @@ func UpdateOption(c *gin.Context) { if option.Value == "true" && common.GitHubClientId == "" { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!", + "message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!", + }) + return + } + case "EmailDomainRestrictionEnabled": + if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!", }) return } diff --git a/controller/relay-aiproxy.go b/controller/relay-aiproxy.go new file mode 100644 index 00000000..d0159ce8 --- /dev/null +++ b/controller/relay-aiproxy.go @@ -0,0 +1,220 @@ +package controller + +import ( + "bufio" + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "strconv" + "strings" +) + +// https://docs.aiproxy.io/dev/library#使用已经定制好的知识库进行对话问答 + +type AIProxyLibraryRequest struct { + Model string `json:"model"` + Query string `json:"query"` + LibraryId string `json:"libraryId"` + Stream bool `json:"stream"` +} + +type AIProxyLibraryError struct { + ErrCode int `json:"errCode"` + Message string `json:"message"` +} + +type AIProxyLibraryDocument struct { + Title string `json:"title"` + URL string `json:"url"` +} + +type AIProxyLibraryResponse struct { + Success bool `json:"success"` + Answer string `json:"answer"` + Documents []AIProxyLibraryDocument `json:"documents"` + AIProxyLibraryError +} + +type AIProxyLibraryStreamResponse struct { + Content string `json:"content"` + Finish bool `json:"finish"` + Model string `json:"model"` + Documents []AIProxyLibraryDocument `json:"documents"` +} + +func requestOpenAI2AIProxyLibrary(request GeneralOpenAIRequest) *AIProxyLibraryRequest { + query := "" + if len(request.Messages) != 0 { + query = request.Messages[len(request.Messages)-1].Content + } + return &AIProxyLibraryRequest{ + Model: request.Model, + Stream: request.Stream, + Query: query, + } +} + +func aiProxyDocuments2Markdown(documents []AIProxyLibraryDocument) string { + if len(documents) == 0 { + return "" + } + content := "\n\n参考文档:\n" + for i, document := range documents { + content += fmt.Sprintf("%d. [%s](%s)\n", i+1, document.Title, document.URL) + } + return content +} + +func responseAIProxyLibrary2OpenAI(response *AIProxyLibraryResponse) *OpenAITextResponse { + content := response.Answer + aiProxyDocuments2Markdown(response.Documents) + choice := OpenAITextResponseChoice{ + Index: 0, + Message: Message{ + Role: "assistant", + Content: content, + }, + FinishReason: "stop", + } + fullTextResponse := OpenAITextResponse{ + Id: common.GetUUID(), + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: []OpenAITextResponseChoice{choice}, + } + return &fullTextResponse +} + +func documentsAIProxyLibrary(documents []AIProxyLibraryDocument) *ChatCompletionsStreamResponse { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = aiProxyDocuments2Markdown(documents) + choice.FinishReason = &stopFinishReason + return &ChatCompletionsStreamResponse{ + Id: common.GetUUID(), + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "", + Choices: []ChatCompletionsStreamResponseChoice{choice}, + } +} + +func streamResponseAIProxyLibrary2OpenAI(response *AIProxyLibraryStreamResponse) *ChatCompletionsStreamResponse { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = response.Content + return &ChatCompletionsStreamResponse{ + Id: common.GetUUID(), + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: response.Model, + Choices: []ChatCompletionsStreamResponseChoice{choice}, + } +} + +func aiProxyLibraryStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var usage Usage + 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"); i >= 0 { + return i + 1, 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) < 5 { // ignore blank line or wrong format + continue + } + if data[:5] != "data:" { + continue + } + data = data[5:] + dataChan <- data + } + stopChan <- true + }() + setEventStreamHeaders(c) + var documents []AIProxyLibraryDocument + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + var AIProxyLibraryResponse AIProxyLibraryStreamResponse + err := json.Unmarshal([]byte(data), &AIProxyLibraryResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + if len(AIProxyLibraryResponse.Documents) != 0 { + documents = AIProxyLibraryResponse.Documents + } + response := streamResponseAIProxyLibrary2OpenAI(&AIProxyLibraryResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + response := documentsAIProxyLibrary(documents) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + err := resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + return nil, &usage +} + +func aiProxyLibraryHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var AIProxyLibraryResponse AIProxyLibraryResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &AIProxyLibraryResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if AIProxyLibraryResponse.ErrCode != 0 { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: AIProxyLibraryResponse.Message, + Type: strconv.Itoa(AIProxyLibraryResponse.ErrCode), + Code: AIProxyLibraryResponse.ErrCode, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseAIProxyLibrary2OpenAI(&AIProxyLibraryResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} diff --git a/controller/relay-ali.go b/controller/relay-ali.go new file mode 100644 index 00000000..50dc743c --- /dev/null +++ b/controller/relay-ali.go @@ -0,0 +1,329 @@ +package controller + +import ( + "bufio" + "encoding/json" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "strings" +) + +// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r + +type AliMessage struct { + User string `json:"user"` + Bot string `json:"bot"` +} + +type AliInput struct { + Prompt string `json:"prompt"` + History []AliMessage `json:"history"` +} + +type AliParameters struct { + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Seed uint64 `json:"seed,omitempty"` + EnableSearch bool `json:"enable_search,omitempty"` +} + +type AliChatRequest struct { + Model string `json:"model"` + Input AliInput `json:"input"` + Parameters AliParameters `json:"parameters,omitempty"` +} + +type AliEmbeddingRequest struct { + Model string `json:"model"` + Input struct { + Texts []string `json:"texts"` + } `json:"input"` + Parameters *struct { + TextType string `json:"text_type,omitempty"` + } `json:"parameters,omitempty"` +} + +type AliEmbedding struct { + Embedding []float64 `json:"embedding"` + TextIndex int `json:"text_index"` +} + +type AliEmbeddingResponse struct { + Output struct { + Embeddings []AliEmbedding `json:"embeddings"` + } `json:"output"` + Usage AliUsage `json:"usage"` + AliError +} + +type AliError struct { + Code string `json:"code"` + Message string `json:"message"` + RequestId string `json:"request_id"` +} + +type AliUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type AliOutput struct { + Text string `json:"text"` + FinishReason string `json:"finish_reason"` +} + +type AliChatResponse struct { + Output AliOutput `json:"output"` + Usage AliUsage `json:"usage"` + AliError +} + +func requestOpenAI2Ali(request GeneralOpenAIRequest) *AliChatRequest { + messages := make([]AliMessage, 0, len(request.Messages)) + prompt := "" + for i := 0; i < len(request.Messages); i++ { + message := request.Messages[i] + if message.Role == "system" { + messages = append(messages, AliMessage{ + User: message.Content, + Bot: "Okay", + }) + continue + } else { + if i == len(request.Messages)-1 { + prompt = message.Content + break + } + messages = append(messages, AliMessage{ + User: message.Content, + Bot: request.Messages[i+1].Content, + }) + i++ + } + } + return &AliChatRequest{ + Model: request.Model, + Input: AliInput{ + Prompt: prompt, + History: messages, + }, + //Parameters: AliParameters{ // ChatGPT's parameters are not compatible with Ali's + // TopP: request.TopP, + // TopK: 50, + // //Seed: 0, + // //EnableSearch: false, + //}, + } +} + +func embeddingRequestOpenAI2Ali(request GeneralOpenAIRequest) *AliEmbeddingRequest { + return &AliEmbeddingRequest{ + Model: "text-embedding-v1", + Input: struct { + Texts []string `json:"texts"` + }{ + Texts: request.ParseInput(), + }, + } +} + +func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var aliResponse AliEmbeddingResponse + err := json.NewDecoder(resp.Body).Decode(&aliResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + + if aliResponse.Code != "" { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: aliResponse.Message, + Type: aliResponse.Code, + Param: aliResponse.RequestId, + Code: aliResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + + fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *OpenAIEmbeddingResponse { + openAIEmbeddingResponse := OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)), + Model: "text-embedding-v1", + Usage: Usage{TotalTokens: response.Usage.TotalTokens}, + } + + for _, item := range response.Output.Embeddings { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, OpenAIEmbeddingResponseItem{ + Object: `embedding`, + Index: item.TextIndex, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} + +func responseAli2OpenAI(response *AliChatResponse) *OpenAITextResponse { + choice := OpenAITextResponseChoice{ + Index: 0, + Message: Message{ + Role: "assistant", + Content: response.Output.Text, + }, + FinishReason: response.Output.FinishReason, + } + fullTextResponse := OpenAITextResponse{ + Id: response.RequestId, + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: []OpenAITextResponseChoice{choice}, + Usage: Usage{ + PromptTokens: response.Usage.InputTokens, + CompletionTokens: response.Usage.OutputTokens, + TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, + }, + } + return &fullTextResponse +} + +func streamResponseAli2OpenAI(aliResponse *AliChatResponse) *ChatCompletionsStreamResponse { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = aliResponse.Output.Text + if aliResponse.Output.FinishReason != "null" { + finishReason := aliResponse.Output.FinishReason + choice.FinishReason = &finishReason + } + response := ChatCompletionsStreamResponse{ + Id: aliResponse.RequestId, + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "ernie-bot", + Choices: []ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func aliStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var usage Usage + 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"); i >= 0 { + return i + 1, 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) < 5 { // ignore blank line or wrong format + continue + } + if data[:5] != "data:" { + continue + } + data = data[5:] + dataChan <- data + } + stopChan <- true + }() + setEventStreamHeaders(c) + lastResponseText := "" + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + var aliResponse AliChatResponse + err := json.Unmarshal([]byte(data), &aliResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + if aliResponse.Usage.OutputTokens != 0 { + usage.PromptTokens = aliResponse.Usage.InputTokens + usage.CompletionTokens = aliResponse.Usage.OutputTokens + usage.TotalTokens = aliResponse.Usage.InputTokens + aliResponse.Usage.OutputTokens + } + response := streamResponseAli2OpenAI(&aliResponse) + response.Choices[0].Delta.Content = strings.TrimPrefix(response.Choices[0].Delta.Content, lastResponseText) + lastResponseText = aliResponse.Output.Text + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + err := resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + return nil, &usage +} + +func aliHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var aliResponse AliChatResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &aliResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if aliResponse.Code != "" { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: aliResponse.Message, + Type: aliResponse.Code, + Param: aliResponse.RequestId, + Code: aliResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseAli2OpenAI(&aliResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} diff --git a/controller/relay-audio.go b/controller/relay-audio.go new file mode 100644 index 00000000..53833108 --- /dev/null +++ b/controller/relay-audio.go @@ -0,0 +1,151 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "one-api/model" +) + +func relayAudioHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { + audioModel := "whisper-1" + + tokenId := c.GetInt("token_id") + channelType := c.GetInt("channel") + channelId := c.GetInt("channel_id") + userId := c.GetInt("id") + group := c.GetString("group") + + preConsumedTokens := common.PreConsumedQuota + modelRatio := common.GetModelRatio(audioModel) + 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-preConsumedQuota < 0 { + return errorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden) + } + err = model.CacheDecreaseUserQuota(userId, preConsumedQuota) + if err != nil { + return errorWrapper(err, "decrease_user_quota_failed", http.StatusInternalServerError) + } + if userQuota > 100*preConsumedQuota { + // in this case, we do not pre-consume quota + // because the user has enough quota + preConsumedQuota = 0 + } + if preConsumedQuota > 0 { + err := model.PreConsumeTokenQuota(tokenId, preConsumedQuota) + if err != nil { + return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusForbidden) + } + } + + // map model name + modelMapping := c.GetString("model_mapping") + 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[audioModel] != "" { + audioModel = modelMap[audioModel] + } + } + + baseURL := common.ChannelBaseURLs[channelType] + requestURL := c.Request.URL.String() + if c.GetString("base_url") != "" { + baseURL = c.GetString("base_url") + } + + fullRequestURL := getFullRequestURL(baseURL, requestURL, channelType) + requestBody := c.Request.Body + + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return errorWrapper(err, "new_request_failed", http.StatusInternalServerError) + } + 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")) + + resp, err := httpClient.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 audioResponse AudioResponse + + defer func(ctx context.Context) { + go func() { + quota := countTokenText(audioResponse.Text, audioModel) + quotaDelta := quota - preConsumedQuota + err := model.PostConsumeTokenQuota(tokenId, quotaDelta) + if err != nil { + common.SysError("error consuming token remain quota: " + err.Error()) + } + err = model.CacheUpdateUserQuota(userId) + if err != nil { + common.SysError("error update user quota cache: " + err.Error()) + } + if quota != 0 { + tokenName := c.GetString("token_name") + logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio) + model.RecordConsumeLog(ctx, userId, channelId, 0, 0, audioModel, tokenName, quota, logContent) + model.UpdateUserUsedQuotaAndRequestCount(userId, quota) + channelId := c.GetInt("channel_id") + model.UpdateChannelUsedQuota(channelId, quota) + } + }() + }(c.Request.Context()) + + 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, &audioResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError) + } + + resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) + + 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 +} diff --git a/controller/relay-baidu.go b/controller/relay-baidu.go new file mode 100644 index 00000000..ed08ac04 --- /dev/null +++ b/controller/relay-baidu.go @@ -0,0 +1,359 @@ +package controller + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "strings" + "sync" + "time" +) + +// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2 + +type BaiduTokenResponse struct { + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` +} + +type BaiduMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type BaiduChatRequest struct { + Messages []BaiduMessage `json:"messages"` + Stream bool `json:"stream"` + UserId string `json:"user_id,omitempty"` +} + +type BaiduError struct { + ErrorCode int `json:"error_code"` + ErrorMsg string `json:"error_msg"` +} + +type BaiduChatResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Result string `json:"result"` + IsTruncated bool `json:"is_truncated"` + NeedClearHistory bool `json:"need_clear_history"` + Usage Usage `json:"usage"` + BaiduError +} + +type BaiduChatStreamResponse struct { + BaiduChatResponse + SentenceId int `json:"sentence_id"` + IsEnd bool `json:"is_end"` +} + +type BaiduEmbeddingRequest struct { + Input []string `json:"input"` +} + +type BaiduEmbeddingData struct { + Object string `json:"object"` + Embedding []float64 `json:"embedding"` + Index int `json:"index"` +} + +type BaiduEmbeddingResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Data []BaiduEmbeddingData `json:"data"` + Usage Usage `json:"usage"` + BaiduError +} + +type BaiduAccessToken struct { + AccessToken string `json:"access_token"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + ExpiresAt time.Time `json:"-"` +} + +var baiduTokenStore sync.Map + +func requestOpenAI2Baidu(request GeneralOpenAIRequest) *BaiduChatRequest { + messages := make([]BaiduMessage, 0, len(request.Messages)) + for _, message := range request.Messages { + if message.Role == "system" { + messages = append(messages, BaiduMessage{ + Role: "user", + Content: message.Content, + }) + messages = append(messages, BaiduMessage{ + Role: "assistant", + Content: "Okay", + }) + } else { + messages = append(messages, BaiduMessage{ + Role: message.Role, + Content: message.Content, + }) + } + } + return &BaiduChatRequest{ + Messages: messages, + Stream: request.Stream, + } +} + +func responseBaidu2OpenAI(response *BaiduChatResponse) *OpenAITextResponse { + choice := OpenAITextResponseChoice{ + Index: 0, + Message: Message{ + Role: "assistant", + Content: response.Result, + }, + FinishReason: "stop", + } + fullTextResponse := OpenAITextResponse{ + Id: response.Id, + Object: "chat.completion", + Created: response.Created, + Choices: []OpenAITextResponseChoice{choice}, + Usage: response.Usage, + } + return &fullTextResponse +} + +func streamResponseBaidu2OpenAI(baiduResponse *BaiduChatStreamResponse) *ChatCompletionsStreamResponse { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = baiduResponse.Result + if baiduResponse.IsEnd { + choice.FinishReason = &stopFinishReason + } + response := ChatCompletionsStreamResponse{ + Id: baiduResponse.Id, + Object: "chat.completion.chunk", + Created: baiduResponse.Created, + Model: "ernie-bot", + Choices: []ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func embeddingRequestOpenAI2Baidu(request GeneralOpenAIRequest) *BaiduEmbeddingRequest { + return &BaiduEmbeddingRequest{ + Input: request.ParseInput(), + } +} + +func embeddingResponseBaidu2OpenAI(response *BaiduEmbeddingResponse) *OpenAIEmbeddingResponse { + openAIEmbeddingResponse := OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]OpenAIEmbeddingResponseItem, 0, len(response.Data)), + Model: "baidu-embedding", + Usage: response.Usage, + } + for _, item := range response.Data { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, OpenAIEmbeddingResponseItem{ + Object: item.Object, + Index: item.Index, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} + +func baiduStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var usage Usage + 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"); i >= 0 { + return i + 1, 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 { // ignore blank line or wrong format + continue + } + data = data[6:] + dataChan <- data + } + stopChan <- true + }() + setEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + var baiduResponse BaiduChatStreamResponse + err := json.Unmarshal([]byte(data), &baiduResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + if baiduResponse.Usage.TotalTokens != 0 { + usage.TotalTokens = baiduResponse.Usage.TotalTokens + usage.PromptTokens = baiduResponse.Usage.PromptTokens + usage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens + } + response := streamResponseBaidu2OpenAI(&baiduResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + err := resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + return nil, &usage +} + +func baiduHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var baiduResponse BaiduChatResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &baiduResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if baiduResponse.ErrorMsg != "" { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: baiduResponse.ErrorMsg, + Type: "baidu_error", + Param: "", + Code: baiduResponse.ErrorCode, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseBaidu2OpenAI(&baiduResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func baiduEmbeddingHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var baiduResponse BaiduEmbeddingResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &baiduResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if baiduResponse.ErrorMsg != "" { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: baiduResponse.ErrorMsg, + Type: "baidu_error", + Param: "", + Code: baiduResponse.ErrorCode, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func getBaiduAccessToken(apiKey string) (string, error) { + if val, ok := baiduTokenStore.Load(apiKey); ok { + var accessToken BaiduAccessToken + if accessToken, ok = val.(BaiduAccessToken); ok { + // soon this will expire + if time.Now().Add(time.Hour).After(accessToken.ExpiresAt) { + go func() { + _, _ = getBaiduAccessTokenHelper(apiKey) + }() + } + return accessToken.AccessToken, nil + } + } + accessToken, err := getBaiduAccessTokenHelper(apiKey) + if err != nil { + return "", err + } + if accessToken == nil { + return "", errors.New("getBaiduAccessToken return a nil token") + } + return (*accessToken).AccessToken, nil +} + +func getBaiduAccessTokenHelper(apiKey string) (*BaiduAccessToken, error) { + parts := strings.Split(apiKey, "|") + if len(parts) != 2 { + return nil, errors.New("invalid baidu apikey") + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s", + parts[0], parts[1]), nil) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + res, err := impatientHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var accessToken BaiduAccessToken + err = json.NewDecoder(res.Body).Decode(&accessToken) + if err != nil { + return nil, err + } + if accessToken.Error != "" { + return nil, errors.New(accessToken.Error + ": " + accessToken.ErrorDescription) + } + if accessToken.AccessToken == "" { + return nil, errors.New("getBaiduAccessTokenHelper get empty access token") + } + accessToken.ExpiresAt = time.Now().Add(time.Duration(accessToken.ExpiresIn) * time.Second) + baiduTokenStore.Store(apiKey, accessToken) + return &accessToken, nil +} diff --git a/controller/relay-claude.go b/controller/relay-claude.go new file mode 100644 index 00000000..1f4a3e7b --- /dev/null +++ b/controller/relay-claude.go @@ -0,0 +1,220 @@ +package controller + +import ( + "bufio" + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "strings" +) + +type ClaudeMetadata struct { + UserId string `json:"user_id"` +} + +type ClaudeRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + MaxTokensToSample int `json:"max_tokens_to_sample"` + StopSequences []string `json:"stop_sequences,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + //ClaudeMetadata `json:"metadata,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type ClaudeError struct { + Type string `json:"type"` + Message string `json:"message"` +} + +type ClaudeResponse struct { + Completion string `json:"completion"` + StopReason string `json:"stop_reason"` + Model string `json:"model"` + Error ClaudeError `json:"error"` +} + +func stopReasonClaude2OpenAI(reason string) string { + switch reason { + case "stop_sequence": + return "stop" + case "max_tokens": + return "length" + default: + return reason + } +} + +func requestOpenAI2Claude(textRequest GeneralOpenAIRequest) *ClaudeRequest { + claudeRequest := ClaudeRequest{ + Model: textRequest.Model, + Prompt: "", + MaxTokensToSample: textRequest.MaxTokens, + StopSequences: nil, + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + Stream: textRequest.Stream, + } + if claudeRequest.MaxTokensToSample == 0 { + claudeRequest.MaxTokensToSample = 1000000 + } + prompt := "" + for _, message := range textRequest.Messages { + if message.Role == "user" { + prompt += fmt.Sprintf("\n\nHuman: %s", message.Content) + } else if message.Role == "assistant" { + prompt += fmt.Sprintf("\n\nAssistant: %s", message.Content) + } else if message.Role == "system" { + prompt += fmt.Sprintf("\n\nSystem: %s", message.Content) + } + } + prompt += "\n\nAssistant:" + claudeRequest.Prompt = prompt + return &claudeRequest +} + +func streamResponseClaude2OpenAI(claudeResponse *ClaudeResponse) *ChatCompletionsStreamResponse { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = claudeResponse.Completion + finishReason := stopReasonClaude2OpenAI(claudeResponse.StopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } + var response ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Model = claudeResponse.Model + response.Choices = []ChatCompletionsStreamResponseChoice{choice} + return &response +} + +func responseClaude2OpenAI(claudeResponse *ClaudeResponse) *OpenAITextResponse { + choice := OpenAITextResponseChoice{ + Index: 0, + Message: Message{ + Role: "assistant", + Content: strings.TrimPrefix(claudeResponse.Completion, " "), + Name: nil, + }, + FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), + } + fullTextResponse := OpenAITextResponse{ + Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()), + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: []OpenAITextResponseChoice{choice}, + } + return &fullTextResponse +} + +func claudeStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, string) { + responseText := "" + responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID()) + createdTime := common.GetTimestamp() + 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), "\r\n\r\n"); i >= 0 { + return i + 4, 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 !strings.HasPrefix(data, "event: completion") { + continue + } + data = strings.TrimPrefix(data, "event: completion\r\ndata: ") + dataChan <- data + } + stopChan <- true + }() + setEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + // some implementations may add \r at the end of data + data = strings.TrimSuffix(data, "\r") + var claudeResponse ClaudeResponse + err := json.Unmarshal([]byte(data), &claudeResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + responseText += claudeResponse.Completion + response := streamResponseClaude2OpenAI(&claudeResponse) + response.Id = responseId + response.Created = createdTime + jsonStr, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonStr)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + err := resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), "" + } + return nil, responseText +} + +func claudeHandler(c *gin.Context, resp *http.Response, promptTokens int, model string) (*OpenAIErrorWithStatusCode, *Usage) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + var claudeResponse ClaudeResponse + err = json.Unmarshal(responseBody, &claudeResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if claudeResponse.Error.Type != "" { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: claudeResponse.Error.Message, + Type: claudeResponse.Error.Type, + Param: "", + Code: claudeResponse.Error.Type, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseClaude2OpenAI(&claudeResponse) + completionTokens := countTokenText(claudeResponse.Completion, model) + usage := Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + } + fullTextResponse.Usage = usage + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &usage +} diff --git a/controller/relay-image.go b/controller/relay-image.go new file mode 100644 index 00000000..ccd52dce --- /dev/null +++ b/controller/relay-image.go @@ -0,0 +1,177 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "one-api/model" +) + +func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { + imageModel := "dall-e" + + tokenId := c.GetInt("token_id") + channelType := c.GetInt("channel") + channelId := c.GetInt("channel_id") + userId := c.GetInt("id") + consumeQuota := c.GetBool("consume_quota") + group := c.GetString("group") + + var imageRequest ImageRequest + if consumeQuota { + err := common.UnmarshalBodyReusable(c, &imageRequest) + if err != nil { + return errorWrapper(err, "bind_request_body_failed", http.StatusBadRequest) + } + } + + // Prompt validation + if imageRequest.Prompt == "" { + return errorWrapper(errors.New("prompt is required"), "required_field_missing", http.StatusBadRequest) + } + + // Not "256x256", "512x512", or "1024x1024" + if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" { + return errorWrapper(errors.New("size must be one of 256x256, 512x512, or 1024x1024"), "invalid_field_value", http.StatusBadRequest) + } + + // N should between 1 and 10 + if imageRequest.N != 0 && (imageRequest.N < 1 || imageRequest.N > 10) { + return errorWrapper(errors.New("n must be between 1 and 10"), "invalid_field_value", 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[imageModel] != "" { + imageModel = modelMap[imageModel] + isModelMapped = true + } + } + baseURL := common.ChannelBaseURLs[channelType] + requestURL := c.Request.URL.String() + if c.GetString("base_url") != "" { + baseURL = c.GetString("base_url") + } + fullRequestURL := getFullRequestURL(baseURL, requestURL, channelType) + var requestBody io.Reader + if isModelMapped { + jsonStr, err := json.Marshal(imageRequest) + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewBuffer(jsonStr) + } else { + requestBody = c.Request.Body + } + + modelRatio := common.GetModelRatio(imageModel) + groupRatio := common.GetGroupRatio(group) + ratio := modelRatio * groupRatio + userQuota, err := model.CacheGetUserQuota(userId) + + sizeRatio := 1.0 + // Size + if imageRequest.Size == "256x256" { + sizeRatio = 1 + } else if imageRequest.Size == "512x512" { + sizeRatio = 1.125 + } else if imageRequest.Size == "1024x1024" { + sizeRatio = 1.25 + } + quota := int(ratio*sizeRatio*1000) * imageRequest.N + + if consumeQuota && userQuota-quota < 0 { + return errorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden) + } + + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return errorWrapper(err, "new_request_failed", http.StatusInternalServerError) + } + 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")) + + resp, err := httpClient.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 ImageResponse + + defer func(ctx context.Context) { + if consumeQuota { + err := model.PostConsumeTokenQuota(tokenId, quota) + if err != nil { + common.SysError("error consuming token remain quota: " + err.Error()) + } + err = model.CacheUpdateUserQuota(userId) + if err != nil { + common.SysError("error update user quota cache: " + err.Error()) + } + if quota != 0 { + tokenName := c.GetString("token_name") + logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio) + model.RecordConsumeLog(ctx, userId, channelId, 0, 0, imageModel, tokenName, quota, logContent) + model.UpdateUserUsedQuotaAndRequestCount(userId, quota) + channelId := c.GetInt("channel_id") + model.UpdateChannelUsedQuota(channelId, quota) + } + } + }(c.Request.Context()) + + 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) + } + + resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) + } + + 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 +} diff --git a/controller/relay-openai.go b/controller/relay-openai.go new file mode 100644 index 00000000..6bdfbc08 --- /dev/null +++ b/controller/relay-openai.go @@ -0,0 +1,144 @@ +package controller + +import ( + "bufio" + "bytes" + "encoding/json" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "strings" +) + +func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*OpenAIErrorWithStatusCode, string) { + responseText := "" + 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"); i >= 0 { + return i + 1, 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 { // ignore blank line or wrong format + continue + } + if data[:6] != "data: " && data[:6] != "[DONE]" { + 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()) + continue // just ignore the error + } + for _, choice := range streamResponse.Choices { + responseText += 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()) + continue + } + for _, choice := range streamResponse.Choices { + responseText += choice.Text + } + } + } + } + stopChan <- true + }() + setEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + if strings.HasPrefix(data, "data: [DONE]") { + data = data[:12] + } + // some implementations may add \r at the end of data + data = strings.TrimSuffix(data, "\r") + 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, responseText +} + +func openaiHandler(c *gin.Context, resp *http.Response, consumeQuota bool, promptTokens int, model string) (*OpenAIErrorWithStatusCode, *Usage) { + var textResponse TextResponse + if consumeQuota { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &textResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if textResponse.Error.Type != "" { + return &OpenAIErrorWithStatusCode{ + OpenAIError: textResponse.Error, + StatusCode: resp.StatusCode, + }, nil + } + // 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 httpClient 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), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + + if textResponse.Usage.TotalTokens == 0 { + completionTokens := 0 + for _, choice := range textResponse.Choices { + completionTokens += countTokenText(choice.Message.Content, model) + } + textResponse.Usage = Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + } + } + return nil, &textResponse.Usage +} diff --git a/controller/relay-palm.go b/controller/relay-palm.go index ae739ca0..a705b318 100644 --- a/controller/relay-palm.go +++ b/controller/relay-palm.go @@ -1,10 +1,17 @@ package controller import ( + "encoding/json" "fmt" "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" ) +// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#request-body +// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#response-body + type PaLMChatMessage struct { Author string `json:"author"` Content string `json:"content"` @@ -15,45 +22,184 @@ type PaLMFilter struct { Message string `json:"message"` } -// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#request-body +type PaLMPrompt struct { + Messages []PaLMChatMessage `json:"messages"` +} + type PaLMChatRequest struct { - Prompt []Message `json:"prompt"` - Temperature float64 `json:"temperature"` - CandidateCount int `json:"candidateCount"` - TopP float64 `json:"topP"` - TopK int `json:"topK"` + Prompt PaLMPrompt `json:"prompt"` + Temperature float64 `json:"temperature,omitempty"` + CandidateCount int `json:"candidateCount,omitempty"` + TopP float64 `json:"topP,omitempty"` + TopK int `json:"topK,omitempty"` +} + +type PaLMError struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` } -// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#response-body type PaLMChatResponse struct { - Candidates []Message `json:"candidates"` - Messages []Message `json:"messages"` - Filters []PaLMFilter `json:"filters"` + Candidates []PaLMChatMessage `json:"candidates"` + Messages []Message `json:"messages"` + Filters []PaLMFilter `json:"filters"` + Error PaLMError `json:"error"` } -func relayPaLM(openAIRequest GeneralOpenAIRequest, c *gin.Context) *OpenAIErrorWithStatusCode { - // https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage - messages := make([]PaLMChatMessage, 0, len(openAIRequest.Messages)) - for _, message := range openAIRequest.Messages { - var author string - if message.Role == "user" { - author = "0" - } else { - author = "1" - } - messages = append(messages, PaLMChatMessage{ - Author: author, +func requestOpenAI2PaLM(textRequest GeneralOpenAIRequest) *PaLMChatRequest { + palmRequest := PaLMChatRequest{ + Prompt: PaLMPrompt{ + Messages: make([]PaLMChatMessage, 0, len(textRequest.Messages)), + }, + Temperature: textRequest.Temperature, + CandidateCount: textRequest.N, + TopP: textRequest.TopP, + TopK: textRequest.MaxTokens, + } + for _, message := range textRequest.Messages { + palmMessage := PaLMChatMessage{ Content: message.Content, - }) + } + if message.Role == "user" { + palmMessage.Author = "0" + } else { + palmMessage.Author = "1" + } + palmRequest.Prompt.Messages = append(palmRequest.Prompt.Messages, palmMessage) } - request := PaLMChatRequest{ - Prompt: nil, - Temperature: openAIRequest.Temperature, - CandidateCount: openAIRequest.N, - TopP: openAIRequest.TopP, - TopK: openAIRequest.MaxTokens, - } - // TODO: forward request to PaLM & convert response - fmt.Print(request) - return nil + return &palmRequest +} + +func responsePaLM2OpenAI(response *PaLMChatResponse) *OpenAITextResponse { + fullTextResponse := OpenAITextResponse{ + Choices: make([]OpenAITextResponseChoice, 0, len(response.Candidates)), + } + for i, candidate := range response.Candidates { + choice := OpenAITextResponseChoice{ + Index: i, + Message: Message{ + Role: "assistant", + Content: candidate.Content, + }, + FinishReason: "stop", + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *ChatCompletionsStreamResponse { + var choice ChatCompletionsStreamResponseChoice + if len(palmResponse.Candidates) > 0 { + choice.Delta.Content = palmResponse.Candidates[0].Content + } + choice.FinishReason = &stopFinishReason + var response ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Model = "palm2" + response.Choices = []ChatCompletionsStreamResponseChoice{choice} + return &response +} + +func palmStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, string) { + responseText := "" + responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID()) + createdTime := common.GetTimestamp() + dataChan := make(chan string) + stopChan := make(chan bool) + go func() { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + common.SysError("error reading stream response: " + err.Error()) + stopChan <- true + return + } + err = resp.Body.Close() + if err != nil { + common.SysError("error closing stream response: " + err.Error()) + stopChan <- true + return + } + var palmResponse PaLMChatResponse + err = json.Unmarshal(responseBody, &palmResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + stopChan <- true + return + } + fullTextResponse := streamResponsePaLM2OpenAI(&palmResponse) + fullTextResponse.Id = responseId + fullTextResponse.Created = createdTime + if len(palmResponse.Candidates) > 0 { + responseText = palmResponse.Candidates[0].Content + } + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + stopChan <- true + return + } + dataChan <- string(jsonResponse) + stopChan <- true + }() + setEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + c.Render(-1, common.CustomEvent{Data: "data: " + data}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + err := resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), "" + } + return nil, responseText +} + +func palmHandler(c *gin.Context, resp *http.Response, promptTokens int, model string) (*OpenAIErrorWithStatusCode, *Usage) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + var palmResponse PaLMChatResponse + err = json.Unmarshal(responseBody, &palmResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if palmResponse.Error.Code != 0 || len(palmResponse.Candidates) == 0 { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: palmResponse.Error.Message, + Type: palmResponse.Error.Status, + Param: "", + Code: palmResponse.Error.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responsePaLM2OpenAI(&palmResponse) + completionTokens := countTokenText(palmResponse.Candidates[0].Content, model) + usage := Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + } + fullTextResponse.Usage = usage + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &usage } diff --git a/controller/relay-tencent.go b/controller/relay-tencent.go new file mode 100644 index 00000000..024468bc --- /dev/null +++ b/controller/relay-tencent.go @@ -0,0 +1,287 @@ +package controller + +import ( + "bufio" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "sort" + "strconv" + "strings" +) + +// https://cloud.tencent.com/document/product/1729/97732 + +type TencentMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type TencentChatRequest struct { + AppId int64 `json:"app_id"` // 腾讯云账号的 APPID + SecretId string `json:"secret_id"` // 官网 SecretId + // Timestamp当前 UNIX 时间戳,单位为秒,可记录发起 API 请求的时间。 + // 例如1529223702,如果与当前时间相差过大,会引起签名过期错误 + Timestamp int64 `json:"timestamp"` + // Expired 签名的有效期,是一个符合 UNIX Epoch 时间戳规范的数值, + // 单位为秒;Expired 必须大于 Timestamp 且 Expired-Timestamp 小于90天 + Expired int64 `json:"expired"` + QueryID string `json:"query_id"` //请求 Id,用于问题排查 + // Temperature 较高的数值会使输出更加随机,而较低的数值会使其更加集中和确定 + // 默认 1.0,取值区间为[0.0,2.0],非必要不建议使用,不合理的取值会影响效果 + // 建议该参数和 top_p 只设置1个,不要同时更改 top_p + Temperature float64 `json:"temperature"` + // TopP 影响输出文本的多样性,取值越大,生成文本的多样性越强 + // 默认1.0,取值区间为[0.0, 1.0],非必要不建议使用, 不合理的取值会影响效果 + // 建议该参数和 temperature 只设置1个,不要同时更改 + TopP float64 `json:"top_p"` + // Stream 0:同步,1:流式 (默认,协议:SSE) + // 同步请求超时:60s,如果内容较长建议使用流式 + Stream int `json:"stream"` + // Messages 会话内容, 长度最多为40, 按对话时间从旧到新在数组中排列 + // 输入 content 总数最大支持 3000 token。 + Messages []TencentMessage `json:"messages"` +} + +type TencentError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type TencentUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type TencentResponseChoices struct { + FinishReason string `json:"finish_reason,omitempty"` // 流式结束标志位,为 stop 则表示尾包 + Messages TencentMessage `json:"messages,omitempty"` // 内容,同步模式返回内容,流模式为 null 输出 content 内容总数最多支持 1024token。 + Delta TencentMessage `json:"delta,omitempty"` // 内容,流模式返回内容,同步模式为 null 输出 content 内容总数最多支持 1024token。 +} + +type TencentChatResponse struct { + Choices []TencentResponseChoices `json:"choices,omitempty"` // 结果 + Created string `json:"created,omitempty"` // unix 时间戳的字符串 + Id string `json:"id,omitempty"` // 会话 id + Usage Usage `json:"usage,omitempty"` // token 数量 + Error TencentError `json:"error,omitempty"` // 错误信息 注意:此字段可能返回 null,表示取不到有效值 + Note string `json:"note,omitempty"` // 注释 + ReqID string `json:"req_id,omitempty"` // 唯一请求 Id,每次请求都会返回。用于反馈接口入参 +} + +func requestOpenAI2Tencent(request GeneralOpenAIRequest) *TencentChatRequest { + messages := make([]TencentMessage, 0, len(request.Messages)) + for i := 0; i < len(request.Messages); i++ { + message := request.Messages[i] + if message.Role == "system" { + messages = append(messages, TencentMessage{ + Role: "user", + Content: message.Content, + }) + messages = append(messages, TencentMessage{ + Role: "assistant", + Content: "Okay", + }) + continue + } + messages = append(messages, TencentMessage{ + Content: message.Content, + Role: message.Role, + }) + } + stream := 0 + if request.Stream { + stream = 1 + } + return &TencentChatRequest{ + Timestamp: common.GetTimestamp(), + Expired: common.GetTimestamp() + 24*60*60, + QueryID: common.GetUUID(), + Temperature: request.Temperature, + TopP: request.TopP, + Stream: stream, + Messages: messages, + } +} + +func responseTencent2OpenAI(response *TencentChatResponse) *OpenAITextResponse { + fullTextResponse := OpenAITextResponse{ + Object: "chat.completion", + Created: common.GetTimestamp(), + Usage: response.Usage, + } + if len(response.Choices) > 0 { + choice := OpenAITextResponseChoice{ + Index: 0, + Message: Message{ + Role: "assistant", + Content: response.Choices[0].Messages.Content, + }, + FinishReason: response.Choices[0].FinishReason, + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponseTencent2OpenAI(TencentResponse *TencentChatResponse) *ChatCompletionsStreamResponse { + response := ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "tencent-hunyuan", + } + if len(TencentResponse.Choices) > 0 { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = TencentResponse.Choices[0].Delta.Content + if TencentResponse.Choices[0].FinishReason == "stop" { + choice.FinishReason = &stopFinishReason + } + response.Choices = append(response.Choices, choice) + } + return &response +} + +func tencentStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, string) { + var responseText string + 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"); i >= 0 { + return i + 1, 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) < 5 { // ignore blank line or wrong format + continue + } + if data[:5] != "data:" { + continue + } + data = data[5:] + dataChan <- data + } + stopChan <- true + }() + setEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + var TencentResponse TencentChatResponse + err := json.Unmarshal([]byte(data), &TencentResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + response := streamResponseTencent2OpenAI(&TencentResponse) + if len(response.Choices) != 0 { + responseText += response.Choices[0].Delta.Content + } + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + err := resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), "" + } + return nil, responseText +} + +func tencentHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var TencentResponse TencentChatResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &TencentResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if TencentResponse.Error.Code != 0 { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: TencentResponse.Error.Message, + Code: TencentResponse.Error.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseTencent2OpenAI(&TencentResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func parseTencentConfig(config string) (appId int64, secretId string, secretKey string, err error) { + parts := strings.Split(config, "|") + if len(parts) != 3 { + err = errors.New("invalid tencent config") + return + } + appId, err = strconv.ParseInt(parts[0], 10, 64) + secretId = parts[1] + secretKey = parts[2] + return +} + +func getTencentSign(req TencentChatRequest, secretKey string) string { + params := make([]string, 0) + params = append(params, "app_id="+strconv.FormatInt(req.AppId, 10)) + params = append(params, "secret_id="+req.SecretId) + params = append(params, "timestamp="+strconv.FormatInt(req.Timestamp, 10)) + params = append(params, "query_id="+req.QueryID) + params = append(params, "temperature="+strconv.FormatFloat(req.Temperature, 'f', -1, 64)) + params = append(params, "top_p="+strconv.FormatFloat(req.TopP, 'f', -1, 64)) + params = append(params, "stream="+strconv.Itoa(req.Stream)) + params = append(params, "expired="+strconv.FormatInt(req.Expired, 10)) + + var messageStr string + for _, msg := range req.Messages { + messageStr += fmt.Sprintf(`{"role":"%s","content":"%s"},`, msg.Role, msg.Content) + } + messageStr = strings.TrimSuffix(messageStr, ",") + params = append(params, "messages=["+messageStr+"]") + + sort.Sort(sort.StringSlice(params)) + url := "hunyuan.cloud.tencent.com/hyllm/v1/chat/completions?" + strings.Join(params, "&") + mac := hmac.New(sha1.New, []byte(secretKey)) + signURL := url + mac.Write([]byte(signURL)) + sign := mac.Sum([]byte(nil)) + return base64.StdEncoding.EncodeToString(sign) +} diff --git a/controller/relay-text.go b/controller/relay-text.go new file mode 100644 index 00000000..a61c6f7c --- /dev/null +++ b/controller/relay-text.go @@ -0,0 +1,645 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "one-api/common" + "one-api/model" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + APITypeOpenAI = iota + APITypeClaude + APITypePaLM + APITypeBaidu + APITypeZhipu + APITypeAli + APITypeXunfei + APITypeAIProxyLibrary + APITypeTencent +) + +var httpClient *http.Client +var impatientHTTPClient *http.Client + +func init() { + if common.RelayTimeout == 0 { + httpClient = &http.Client{} + } else { + httpClient = &http.Client{ + Timeout: time.Duration(common.RelayTimeout) * time.Second, + } + } + + impatientHTTPClient = &http.Client{ + Timeout: 5 * time.Second, + } +} + +func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { + channelType := c.GetInt("channel") + channelId := c.GetInt("channel_id") + 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 == RelayModeModerations && textRequest.Model == "" { + textRequest.Model = "text-moderation-latest" + } + if relayMode == RelayModeEmbeddings && textRequest.Model == "" { + textRequest.Model = c.Param("model") + } + // 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 != "" && 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 + } + } + apiType := APITypeOpenAI + switch channelType { + case common.ChannelTypeAnthropic: + apiType = APITypeClaude + case common.ChannelTypeBaidu: + apiType = APITypeBaidu + case common.ChannelTypePaLM: + apiType = APITypePaLM + case common.ChannelTypeZhipu: + apiType = APITypeZhipu + case common.ChannelTypeAli: + apiType = APITypeAli + case common.ChannelTypeXunfei: + apiType = APITypeXunfei + case common.ChannelTypeAIProxyLibrary: + apiType = APITypeAIProxyLibrary + case common.ChannelTypeTencent: + apiType = APITypeTencent + } + baseURL := common.ChannelBaseURLs[channelType] + requestURL := c.Request.URL.String() + if c.GetString("base_url") != "" { + baseURL = c.GetString("base_url") + } + fullRequestURL := getFullRequestURL(baseURL, requestURL, channelType) + switch apiType { + case APITypeOpenAI: + 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) + } + case APITypeClaude: + fullRequestURL = "https://api.anthropic.com/v1/complete" + if baseURL != "" { + fullRequestURL = fmt.Sprintf("%s/v1/complete", baseURL) + } + case APITypeBaidu: + switch textRequest.Model { + case "ERNIE-Bot": + fullRequestURL = "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/completions" + case "ERNIE-Bot-turbo": + fullRequestURL = "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/eb-instant" + case "ERNIE-Bot-4": + fullRequestURL = "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/completions_pro" + case "BLOOMZ-7B": + fullRequestURL = "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/bloomz_7b1" + case "Embedding-V1": + fullRequestURL = "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/embeddings/embedding-v1" + } + apiKey := c.Request.Header.Get("Authorization") + apiKey = strings.TrimPrefix(apiKey, "Bearer ") + var err error + if apiKey, err = getBaiduAccessToken(apiKey); err != nil { + return errorWrapper(err, "invalid_baidu_config", http.StatusInternalServerError) + } + fullRequestURL += "?access_token=" + apiKey + case APITypePaLM: + fullRequestURL = "https://generativelanguage.googleapis.com/v1beta2/models/chat-bison-001:generateMessage" + if baseURL != "" { + fullRequestURL = fmt.Sprintf("%s/v1beta2/models/chat-bison-001:generateMessage", baseURL) + } + apiKey := c.Request.Header.Get("Authorization") + apiKey = strings.TrimPrefix(apiKey, "Bearer ") + fullRequestURL += "?key=" + apiKey + case APITypeZhipu: + method := "invoke" + if textRequest.Stream { + method = "sse-invoke" + } + fullRequestURL = fmt.Sprintf("https://open.bigmodel.cn/api/paas/v3/model-api/%s/%s", textRequest.Model, method) + case APITypeAli: + fullRequestURL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation" + if relayMode == RelayModeEmbeddings { + fullRequestURL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding" + } + case APITypeTencent: + fullRequestURL = "https://hunyuan.cloud.tencent.com/hyllm/v1/chat/completions" + case APITypeAIProxyLibrary: + fullRequestURL = fmt.Sprintf("%s/api/library/ask", baseURL) + } + var promptTokens int + var completionTokens int + switch relayMode { + case RelayModeChatCompletions: + promptTokens = countTokenMessages(textRequest.Messages, textRequest.Model) + case RelayModeCompletions: + promptTokens = countTokenInput(textRequest.Prompt, textRequest.Model) + case RelayModeModerations: + 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-preConsumedQuota < 0 { + return errorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden) + } + err = model.CacheDecreaseUserQuota(userId, preConsumedQuota) + if err != nil { + return errorWrapper(err, "decrease_user_quota_failed", http.StatusInternalServerError) + } + if userQuota > 100*preConsumedQuota { + // in this case, we do not pre-consume quota + // because the user has enough quota + preConsumedQuota = 0 + common.LogInfo(c.Request.Context(), fmt.Sprintf("user %d has enough quota %d, trusted and no need to pre-consume", userId, userQuota)) + } + if consumeQuota && preConsumedQuota > 0 { + err := model.PreConsumeTokenQuota(tokenId, preConsumedQuota) + if err != nil { + return errorWrapper(err, "pre_consume_token_quota_failed", http.StatusForbidden) + } + } + 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 + } + switch apiType { + case APITypeClaude: + claudeRequest := requestOpenAI2Claude(textRequest) + jsonStr, err := json.Marshal(claudeRequest) + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewBuffer(jsonStr) + case APITypeBaidu: + var jsonData []byte + var err error + switch relayMode { + case RelayModeEmbeddings: + baiduEmbeddingRequest := embeddingRequestOpenAI2Baidu(textRequest) + jsonData, err = json.Marshal(baiduEmbeddingRequest) + default: + baiduRequest := requestOpenAI2Baidu(textRequest) + jsonData, err = json.Marshal(baiduRequest) + } + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewBuffer(jsonData) + case APITypePaLM: + palmRequest := requestOpenAI2PaLM(textRequest) + jsonStr, err := json.Marshal(palmRequest) + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewBuffer(jsonStr) + case APITypeZhipu: + zhipuRequest := requestOpenAI2Zhipu(textRequest) + jsonStr, err := json.Marshal(zhipuRequest) + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewBuffer(jsonStr) + case APITypeAli: + var jsonStr []byte + var err error + switch relayMode { + case RelayModeEmbeddings: + aliEmbeddingRequest := embeddingRequestOpenAI2Ali(textRequest) + jsonStr, err = json.Marshal(aliEmbeddingRequest) + default: + aliRequest := requestOpenAI2Ali(textRequest) + jsonStr, err = json.Marshal(aliRequest) + } + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewBuffer(jsonStr) + case APITypeTencent: + apiKey := c.Request.Header.Get("Authorization") + apiKey = strings.TrimPrefix(apiKey, "Bearer ") + appId, secretId, secretKey, err := parseTencentConfig(apiKey) + if err != nil { + return errorWrapper(err, "invalid_tencent_config", http.StatusInternalServerError) + } + tencentRequest := requestOpenAI2Tencent(textRequest) + tencentRequest.AppId = appId + tencentRequest.SecretId = secretId + jsonStr, err := json.Marshal(tencentRequest) + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + sign := getTencentSign(*tencentRequest, secretKey) + c.Request.Header.Set("Authorization", sign) + requestBody = bytes.NewBuffer(jsonStr) + case APITypeAIProxyLibrary: + aiProxyLibraryRequest := requestOpenAI2AIProxyLibrary(textRequest) + aiProxyLibraryRequest.LibraryId = c.GetString("library_id") + jsonStr, err := json.Marshal(aiProxyLibraryRequest) + if err != nil { + return errorWrapper(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + requestBody = bytes.NewBuffer(jsonStr) + } + + var req *http.Request + var resp *http.Response + isStream := textRequest.Stream + + if apiType != APITypeXunfei { // cause xunfei use websocket + req, err = http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return errorWrapper(err, "new_request_failed", http.StatusInternalServerError) + } + apiKey := c.Request.Header.Get("Authorization") + apiKey = strings.TrimPrefix(apiKey, "Bearer ") + switch apiType { + case APITypeOpenAI: + if channelType == common.ChannelTypeAzure { + req.Header.Set("api-key", apiKey) + } else { + req.Header.Set("Authorization", c.Request.Header.Get("Authorization")) + if channelType == common.ChannelTypeOpenRouter { + req.Header.Set("HTTP-Referer", "https://github.com/songquanpeng/one-api") + req.Header.Set("X-Title", "One API") + } + } + case APITypeClaude: + req.Header.Set("x-api-key", apiKey) + anthropicVersion := c.Request.Header.Get("anthropic-version") + if anthropicVersion == "" { + anthropicVersion = "2023-06-01" + } + req.Header.Set("anthropic-version", anthropicVersion) + case APITypeZhipu: + token := getZhipuToken(apiKey) + req.Header.Set("Authorization", token) + case APITypeAli: + req.Header.Set("Authorization", "Bearer "+apiKey) + if textRequest.Stream { + req.Header.Set("X-DashScope-SSE", "enable") + } + case APITypeTencent: + req.Header.Set("Authorization", apiKey) + default: + req.Header.Set("Authorization", "Bearer "+apiKey) + } + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + req.Header.Set("Accept", c.Request.Header.Get("Accept")) + if isStream && c.Request.Header.Get("Accept") == "" { + req.Header.Set("Accept", "text/event-stream") + } + //req.Header.Set("Connection", c.Request.Header.Get("Connection")) + resp, err = httpClient.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) + } + isStream = isStream || strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") + + if resp.StatusCode != http.StatusOK { + if preConsumedQuota != 0 { + go func(ctx context.Context) { + // return pre-consumed quota + err := model.PostConsumeTokenQuota(tokenId, -preConsumedQuota) + if err != nil { + common.LogError(ctx, "error return pre-consumed quota: "+err.Error()) + } + }(c.Request.Context()) + } + return relayErrorHandler(resp) + } + } + + var textResponse TextResponse + tokenName := c.GetString("token_name") + + defer func(ctx context.Context) { + // c.Writer.Flush() + go func() { + if consumeQuota { + quota := 0 + completionRatio := common.GetCompletionRatio(textRequest.Model) + promptTokens = textResponse.Usage.PromptTokens + completionTokens = textResponse.Usage.CompletionTokens + quota = int(math.Ceil((float64(promptTokens) + float64(completionTokens)*completionRatio) * ratio)) + if ratio != 0 && quota <= 0 { + 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 + err := model.PostConsumeTokenQuota(tokenId, quotaDelta) + if err != nil { + common.LogError(ctx, "error consuming token remain quota: "+err.Error()) + } + err = model.CacheUpdateUserQuota(userId) + if err != nil { + common.LogError(ctx, "error update user quota cache: "+err.Error()) + } + if quota != 0 { + logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio) + model.RecordConsumeLog(ctx, userId, channelId, promptTokens, completionTokens, textRequest.Model, tokenName, quota, logContent) + model.UpdateUserUsedQuotaAndRequestCount(userId, quota) + model.UpdateChannelUsedQuota(channelId, quota) + } + } + }() + }(c.Request.Context()) + switch apiType { + case APITypeOpenAI: + if isStream { + err, responseText := openaiStreamHandler(c, resp, relayMode) + if err != nil { + return err + } + textResponse.Usage.PromptTokens = promptTokens + textResponse.Usage.CompletionTokens = countTokenText(responseText, textRequest.Model) + return nil + } else { + err, usage := openaiHandler(c, resp, consumeQuota, promptTokens, textRequest.Model) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } + case APITypeClaude: + if isStream { + err, responseText := claudeStreamHandler(c, resp) + if err != nil { + return err + } + textResponse.Usage.PromptTokens = promptTokens + textResponse.Usage.CompletionTokens = countTokenText(responseText, textRequest.Model) + return nil + } else { + err, usage := claudeHandler(c, resp, promptTokens, textRequest.Model) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } + case APITypeBaidu: + if isStream { + err, usage := baiduStreamHandler(c, resp) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } else { + var err *OpenAIErrorWithStatusCode + var usage *Usage + switch relayMode { + case RelayModeEmbeddings: + err, usage = baiduEmbeddingHandler(c, resp) + default: + err, usage = baiduHandler(c, resp) + } + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } + case APITypePaLM: + if textRequest.Stream { // PaLM2 API does not support stream + err, responseText := palmStreamHandler(c, resp) + if err != nil { + return err + } + textResponse.Usage.PromptTokens = promptTokens + textResponse.Usage.CompletionTokens = countTokenText(responseText, textRequest.Model) + return nil + } else { + err, usage := palmHandler(c, resp, promptTokens, textRequest.Model) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } + case APITypeZhipu: + if isStream { + err, usage := zhipuStreamHandler(c, resp) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + // zhipu's API does not return prompt tokens & completion tokens + textResponse.Usage.PromptTokens = textResponse.Usage.TotalTokens + return nil + } else { + err, usage := zhipuHandler(c, resp) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + // zhipu's API does not return prompt tokens & completion tokens + textResponse.Usage.PromptTokens = textResponse.Usage.TotalTokens + return nil + } + case APITypeAli: + if isStream { + err, usage := aliStreamHandler(c, resp) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } else { + var err *OpenAIErrorWithStatusCode + var usage *Usage + switch relayMode { + case RelayModeEmbeddings: + err, usage = aliEmbeddingHandler(c, resp) + default: + err, usage = aliHandler(c, resp) + } + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } + case APITypeXunfei: + auth := c.Request.Header.Get("Authorization") + auth = strings.TrimPrefix(auth, "Bearer ") + splits := strings.Split(auth, "|") + if len(splits) != 3 { + return errorWrapper(errors.New("invalid auth"), "invalid_auth", http.StatusBadRequest) + } + var err *OpenAIErrorWithStatusCode + var usage *Usage + if isStream { + err, usage = xunfeiStreamHandler(c, textRequest, splits[0], splits[1], splits[2]) + } else { + err, usage = xunfeiHandler(c, textRequest, splits[0], splits[1], splits[2]) + } + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + case APITypeAIProxyLibrary: + if isStream { + err, usage := aiProxyLibraryStreamHandler(c, resp) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } else { + err, usage := aiProxyLibraryHandler(c, resp) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } + case APITypeTencent: + if isStream { + err, responseText := tencentStreamHandler(c, resp) + if err != nil { + return err + } + textResponse.Usage.PromptTokens = promptTokens + textResponse.Usage.CompletionTokens = countTokenText(responseText, textRequest.Model) + return nil + } else { + err, usage := tencentHandler(c, resp) + if err != nil { + return err + } + if usage != nil { + textResponse.Usage = *usage + } + return nil + } + default: + return errorWrapper(errors.New("unknown api type"), "unknown_api_type", http.StatusInternalServerError) + } +} diff --git a/controller/relay-utils.go b/controller/relay-utils.go index bb25fa3b..cf5d9b69 100644 --- a/controller/relay-utils.go +++ b/controller/relay-utils.go @@ -1,28 +1,68 @@ package controller import ( + "encoding/json" "fmt" + "github.com/gin-gonic/gin" "github.com/pkoukk/tiktoken-go" + "io" + "net/http" "one-api/common" + "strconv" "strings" ) -var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} +var stopFinishReason = "stop" -func getTokenEncoder(model string) *tiktoken.Tiktoken { - if tokenEncoder, ok := tokenEncoderMap[model]; ok { - return tokenEncoder - } - tokenEncoder, err := tiktoken.EncodingForModel(model) +// tokenEncoderMap won't grow after initialization +var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} +var defaultTokenEncoder *tiktoken.Tiktoken + +func InitTokenEncoders() { + common.SysLog("initializing token encoders") + gpt35TokenEncoder, err := tiktoken.EncodingForModel("gpt-3.5-turbo") if err != nil { - common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error())) - tokenEncoder, err = tiktoken.EncodingForModel("gpt-3.5-turbo") - if err != nil { - common.FatalLog(fmt.Sprintf("failed to get token encoder for model gpt-3.5-turbo: %s", err.Error())) + common.FatalLog(fmt.Sprintf("failed to get gpt-3.5-turbo token encoder: %s", err.Error())) + } + defaultTokenEncoder = gpt35TokenEncoder + gpt4TokenEncoder, err := tiktoken.EncodingForModel("gpt-4") + if err != nil { + common.FatalLog(fmt.Sprintf("failed to get gpt-4 token encoder: %s", err.Error())) + } + for model, _ := range common.ModelRatio { + if strings.HasPrefix(model, "gpt-3.5") { + tokenEncoderMap[model] = gpt35TokenEncoder + } else if strings.HasPrefix(model, "gpt-4") { + tokenEncoderMap[model] = gpt4TokenEncoder + } else { + tokenEncoderMap[model] = nil } } - tokenEncoderMap[model] = tokenEncoder - return tokenEncoder + common.SysLog("token encoders initialized") +} + +func getTokenEncoder(model string) *tiktoken.Tiktoken { + tokenEncoder, ok := tokenEncoderMap[model] + if ok && tokenEncoder != nil { + return tokenEncoder + } + if ok { + tokenEncoder, err := tiktoken.EncodingForModel(model) + if err != nil { + common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error())) + tokenEncoder = defaultTokenEncoder + } + tokenEncoderMap[model] = tokenEncoder + return tokenEncoder + } + return defaultTokenEncoder +} + +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 { @@ -34,12 +74,9 @@ func countTokenMessages(messages []Message, model string) int { // Every message follows <|start|>{role/name}\n{content}<|end|>\n var tokensPerMessage int var tokensPerName int - if strings.HasPrefix(model, "gpt-3.5") { + if model == "gpt-3.5-turbo-0301" { tokensPerMessage = 4 tokensPerName = -1 // If there's a name, the role is omitted - } else if strings.HasPrefix(model, "gpt-4") { - tokensPerMessage = 3 - tokensPerName = 1 } else { tokensPerMessage = 3 tokensPerName = 1 @@ -47,19 +84,105 @@ func countTokenMessages(messages []Message, model string) int { tokenNum := 0 for _, message := range messages { tokenNum += tokensPerMessage - tokenNum += len(tokenEncoder.Encode(message.Content, nil, nil)) - tokenNum += len(tokenEncoder.Encode(message.Role, nil, nil)) + tokenNum += getTokenNum(tokenEncoder, message.Content) + tokenNum += getTokenNum(tokenEncoder, message.Role) if message.Name != nil { 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|> 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) + return getTokenNum(tokenEncoder, text) +} + +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 shouldDisableChannel(err *OpenAIError, statusCode int) bool { + if !common.AutomaticDisableChannelEnabled { + return false + } + if err == nil { + return false + } + if statusCode == http.StatusUnauthorized { + return true + } + if err.Type == "insufficient_quota" || err.Code == "invalid_api_key" || err.Code == "account_deactivated" { + return true + } + return false +} + +func setEventStreamHeaders(c *gin.Context) { + 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") +} + +func relayErrorHandler(resp *http.Response) (openAIErrorWithStatusCode *OpenAIErrorWithStatusCode) { + openAIErrorWithStatusCode = &OpenAIErrorWithStatusCode{ + StatusCode: resp.StatusCode, + OpenAIError: OpenAIError{ + Message: fmt.Sprintf("bad response status code %d", resp.StatusCode), + Type: "upstream_error", + Code: "bad_response_status_code", + Param: strconv.Itoa(resp.StatusCode), + }, + } + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return + } + err = resp.Body.Close() + if err != nil { + return + } + var textResponse TextResponse + err = json.Unmarshal(responseBody, &textResponse) + if err != nil { + return + } + openAIErrorWithStatusCode.OpenAIError = textResponse.Error + return +} + +func getFullRequestURL(baseURL string, requestURL string, channelType int) string { + fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + if channelType == common.ChannelTypeOpenAI { + if strings.HasPrefix(baseURL, "https://gateway.ai.cloudflare.com") { + fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/v1")) + } + } + return fullRequestURL } diff --git a/controller/relay-xunfei.go b/controller/relay-xunfei.go new file mode 100644 index 00000000..91fb6042 --- /dev/null +++ b/controller/relay-xunfei.go @@ -0,0 +1,306 @@ +package controller + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "io" + "net/http" + "net/url" + "one-api/common" + "strings" + "time" +) + +// https://console.xfyun.cn/services/cbm +// https://www.xfyun.cn/doc/spark/Web.html + +type XunfeiMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type XunfeiChatRequest struct { + Header struct { + AppId string `json:"app_id"` + } `json:"header"` + Parameter struct { + Chat struct { + Domain string `json:"domain,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopK int `json:"top_k,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Auditing bool `json:"auditing,omitempty"` + } `json:"chat"` + } `json:"parameter"` + Payload struct { + Message struct { + Text []XunfeiMessage `json:"text"` + } `json:"message"` + } `json:"payload"` +} + +type XunfeiChatResponseTextItem struct { + Content string `json:"content"` + Role string `json:"role"` + Index int `json:"index"` +} + +type XunfeiChatResponse struct { + Header struct { + Code int `json:"code"` + Message string `json:"message"` + Sid string `json:"sid"` + Status int `json:"status"` + } `json:"header"` + Payload struct { + Choices struct { + Status int `json:"status"` + Seq int `json:"seq"` + Text []XunfeiChatResponseTextItem `json:"text"` + } `json:"choices"` + Usage struct { + //Text struct { + // QuestionTokens string `json:"question_tokens"` + // PromptTokens string `json:"prompt_tokens"` + // CompletionTokens string `json:"completion_tokens"` + // TotalTokens string `json:"total_tokens"` + //} `json:"text"` + Text Usage `json:"text"` + } `json:"usage"` + } `json:"payload"` +} + +func requestOpenAI2Xunfei(request GeneralOpenAIRequest, xunfeiAppId string, domain string) *XunfeiChatRequest { + messages := make([]XunfeiMessage, 0, len(request.Messages)) + for _, message := range request.Messages { + if message.Role == "system" { + messages = append(messages, XunfeiMessage{ + Role: "user", + Content: message.Content, + }) + messages = append(messages, XunfeiMessage{ + Role: "assistant", + Content: "Okay", + }) + } else { + messages = append(messages, XunfeiMessage{ + Role: message.Role, + Content: message.Content, + }) + } + } + xunfeiRequest := XunfeiChatRequest{} + xunfeiRequest.Header.AppId = xunfeiAppId + xunfeiRequest.Parameter.Chat.Domain = domain + xunfeiRequest.Parameter.Chat.Temperature = request.Temperature + xunfeiRequest.Parameter.Chat.TopK = request.N + xunfeiRequest.Parameter.Chat.MaxTokens = request.MaxTokens + xunfeiRequest.Payload.Message.Text = messages + return &xunfeiRequest +} + +func responseXunfei2OpenAI(response *XunfeiChatResponse) *OpenAITextResponse { + if len(response.Payload.Choices.Text) == 0 { + response.Payload.Choices.Text = []XunfeiChatResponseTextItem{ + { + Content: "", + }, + } + } + choice := OpenAITextResponseChoice{ + Index: 0, + Message: Message{ + Role: "assistant", + Content: response.Payload.Choices.Text[0].Content, + }, + FinishReason: stopFinishReason, + } + fullTextResponse := OpenAITextResponse{ + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: []OpenAITextResponseChoice{choice}, + Usage: response.Payload.Usage.Text, + } + return &fullTextResponse +} + +func streamResponseXunfei2OpenAI(xunfeiResponse *XunfeiChatResponse) *ChatCompletionsStreamResponse { + if len(xunfeiResponse.Payload.Choices.Text) == 0 { + xunfeiResponse.Payload.Choices.Text = []XunfeiChatResponseTextItem{ + { + Content: "", + }, + } + } + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = xunfeiResponse.Payload.Choices.Text[0].Content + if xunfeiResponse.Payload.Choices.Status == 2 { + choice.FinishReason = &stopFinishReason + } + response := ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "SparkDesk", + Choices: []ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func buildXunfeiAuthUrl(hostUrl string, apiKey, apiSecret string) string { + HmacWithShaToBase64 := func(algorithm, data, key string) string { + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(data)) + encodeData := mac.Sum(nil) + return base64.StdEncoding.EncodeToString(encodeData) + } + ul, err := url.Parse(hostUrl) + if err != nil { + fmt.Println(err) + } + date := time.Now().UTC().Format(time.RFC1123) + signString := []string{"host: " + ul.Host, "date: " + date, "GET " + ul.Path + " HTTP/1.1"} + sign := strings.Join(signString, "\n") + sha := HmacWithShaToBase64("hmac-sha256", sign, apiSecret) + authUrl := fmt.Sprintf("hmac username=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"", apiKey, + "hmac-sha256", "host date request-line", sha) + authorization := base64.StdEncoding.EncodeToString([]byte(authUrl)) + v := url.Values{} + v.Add("host", ul.Host) + v.Add("date", date) + v.Add("authorization", authorization) + callUrl := hostUrl + "?" + v.Encode() + return callUrl +} + +func xunfeiStreamHandler(c *gin.Context, textRequest GeneralOpenAIRequest, appId string, apiSecret string, apiKey string) (*OpenAIErrorWithStatusCode, *Usage) { + domain, authUrl := getXunfeiAuthUrl(c, apiKey, apiSecret) + dataChan, stopChan, err := xunfeiMakeRequest(textRequest, domain, authUrl, appId) + if err != nil { + return errorWrapper(err, "make xunfei request err", http.StatusInternalServerError), nil + } + setEventStreamHeaders(c) + var usage Usage + c.Stream(func(w io.Writer) bool { + select { + case xunfeiResponse := <-dataChan: + usage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens + usage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens + usage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens + response := streamResponseXunfei2OpenAI(&xunfeiResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + return nil, &usage +} + +func xunfeiHandler(c *gin.Context, textRequest GeneralOpenAIRequest, appId string, apiSecret string, apiKey string) (*OpenAIErrorWithStatusCode, *Usage) { + domain, authUrl := getXunfeiAuthUrl(c, apiKey, apiSecret) + dataChan, stopChan, err := xunfeiMakeRequest(textRequest, domain, authUrl, appId) + if err != nil { + return errorWrapper(err, "make xunfei request err", http.StatusInternalServerError), nil + } + var usage Usage + var content string + var xunfeiResponse XunfeiChatResponse + stop := false + for !stop { + select { + case xunfeiResponse = <-dataChan: + if len(xunfeiResponse.Payload.Choices.Text) == 0 { + continue + } + content += xunfeiResponse.Payload.Choices.Text[0].Content + usage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens + usage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens + usage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens + case stop = <-stopChan: + } + } + + xunfeiResponse.Payload.Choices.Text[0].Content = content + + response := responseXunfei2OpenAI(&xunfeiResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + _, _ = c.Writer.Write(jsonResponse) + return nil, &usage +} + +func xunfeiMakeRequest(textRequest GeneralOpenAIRequest, domain, authUrl, appId string) (chan XunfeiChatResponse, chan bool, error) { + d := websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + } + conn, resp, err := d.Dial(authUrl, nil) + if err != nil || resp.StatusCode != 101 { + return nil, nil, err + } + data := requestOpenAI2Xunfei(textRequest, appId, domain) + err = conn.WriteJSON(data) + if err != nil { + return nil, nil, err + } + + dataChan := make(chan XunfeiChatResponse) + stopChan := make(chan bool) + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + common.SysError("error reading stream response: " + err.Error()) + break + } + var response XunfeiChatResponse + err = json.Unmarshal(msg, &response) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + break + } + dataChan <- response + if response.Payload.Choices.Status == 2 { + err := conn.Close() + if err != nil { + common.SysError("error closing websocket connection: " + err.Error()) + } + break + } + } + stopChan <- true + }() + + return dataChan, stopChan, nil +} + +func getXunfeiAuthUrl(c *gin.Context, apiKey string, apiSecret string) (string, string) { + query := c.Request.URL.Query() + apiVersion := query.Get("api-version") + if apiVersion == "" { + apiVersion = c.GetString("api_version") + } + if apiVersion == "" { + apiVersion = "v1.1" + common.SysLog("api_version not found, use default: " + apiVersion) + } + domain := "general" + if apiVersion != "v1.1" { + domain += strings.Split(apiVersion, ".")[0] + } + authUrl := buildXunfeiAuthUrl(fmt.Sprintf("wss://spark-api.xf-yun.com/%s/chat", apiVersion), apiKey, apiSecret) + return domain, authUrl +} diff --git a/controller/relay-zhipu.go b/controller/relay-zhipu.go new file mode 100644 index 00000000..7a4a582d --- /dev/null +++ b/controller/relay-zhipu.go @@ -0,0 +1,301 @@ +package controller + +import ( + "bufio" + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" + "io" + "net/http" + "one-api/common" + "strings" + "sync" + "time" +) + +// https://open.bigmodel.cn/doc/api#chatglm_std +// chatglm_std, chatglm_lite +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke + +type ZhipuMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ZhipuRequest struct { + Prompt []ZhipuMessage `json:"prompt"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + RequestId string `json:"request_id,omitempty"` + Incremental bool `json:"incremental,omitempty"` +} + +type ZhipuResponseData struct { + TaskId string `json:"task_id"` + RequestId string `json:"request_id"` + TaskStatus string `json:"task_status"` + Choices []ZhipuMessage `json:"choices"` + Usage `json:"usage"` +} + +type ZhipuResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Success bool `json:"success"` + Data ZhipuResponseData `json:"data"` +} + +type ZhipuStreamMetaResponse struct { + RequestId string `json:"request_id"` + TaskId string `json:"task_id"` + TaskStatus string `json:"task_status"` + Usage `json:"usage"` +} + +type zhipuTokenData struct { + Token string + ExpiryTime time.Time +} + +var zhipuTokens sync.Map +var expSeconds int64 = 24 * 3600 + +func getZhipuToken(apikey string) string { + data, ok := zhipuTokens.Load(apikey) + if ok { + tokenData := data.(zhipuTokenData) + if time.Now().Before(tokenData.ExpiryTime) { + return tokenData.Token + } + } + + split := strings.Split(apikey, ".") + if len(split) != 2 { + common.SysError("invalid zhipu key: " + apikey) + return "" + } + + id := split[0] + secret := split[1] + + expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6 + expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second) + + timestamp := time.Now().UnixNano() / 1e6 + + payload := jwt.MapClaims{ + "api_key": id, + "exp": expMillis, + "timestamp": timestamp, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) + + token.Header["alg"] = "HS256" + token.Header["sign_type"] = "SIGN" + + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return "" + } + + zhipuTokens.Store(apikey, zhipuTokenData{ + Token: tokenString, + ExpiryTime: expiryTime, + }) + + return tokenString +} + +func requestOpenAI2Zhipu(request GeneralOpenAIRequest) *ZhipuRequest { + messages := make([]ZhipuMessage, 0, len(request.Messages)) + for _, message := range request.Messages { + if message.Role == "system" { + messages = append(messages, ZhipuMessage{ + Role: "system", + Content: message.Content, + }) + messages = append(messages, ZhipuMessage{ + Role: "user", + Content: "Okay", + }) + } else { + messages = append(messages, ZhipuMessage{ + Role: message.Role, + Content: message.Content, + }) + } + } + return &ZhipuRequest{ + Prompt: messages, + Temperature: request.Temperature, + TopP: request.TopP, + Incremental: false, + } +} + +func responseZhipu2OpenAI(response *ZhipuResponse) *OpenAITextResponse { + fullTextResponse := OpenAITextResponse{ + Id: response.Data.TaskId, + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: make([]OpenAITextResponseChoice, 0, len(response.Data.Choices)), + Usage: response.Data.Usage, + } + for i, choice := range response.Data.Choices { + openaiChoice := OpenAITextResponseChoice{ + Index: i, + Message: Message{ + Role: choice.Role, + Content: strings.Trim(choice.Content, "\""), + }, + FinishReason: "", + } + if i == len(response.Data.Choices)-1 { + openaiChoice.FinishReason = "stop" + } + fullTextResponse.Choices = append(fullTextResponse.Choices, openaiChoice) + } + return &fullTextResponse +} + +func streamResponseZhipu2OpenAI(zhipuResponse string) *ChatCompletionsStreamResponse { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = zhipuResponse + response := ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "chatglm", + Choices: []ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*ChatCompletionsStreamResponse, *Usage) { + var choice ChatCompletionsStreamResponseChoice + choice.Delta.Content = "" + choice.FinishReason = &stopFinishReason + response := ChatCompletionsStreamResponse{ + Id: zhipuResponse.RequestId, + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "chatglm", + Choices: []ChatCompletionsStreamResponseChoice{choice}, + } + return &response, &zhipuResponse.Usage +} + +func zhipuStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var usage *Usage + 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 && strings.Index(string(data), ":") >= 0 { + return i + 2, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + }) + dataChan := make(chan string) + metaChan := make(chan string) + stopChan := make(chan bool) + go func() { + for scanner.Scan() { + data := scanner.Text() + lines := strings.Split(data, "\n") + for i, line := range lines { + if len(line) < 5 { + continue + } + if line[:5] == "data:" { + dataChan <- line[5:] + if i != len(lines)-1 { + dataChan <- "\n" + } + } else if line[:5] == "meta:" { + metaChan <- line[5:] + } + } + } + stopChan <- true + }() + setEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + response := streamResponseZhipu2OpenAI(data) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case data := <-metaChan: + var zhipuResponse ZhipuStreamMetaResponse + err := json.Unmarshal([]byte(data), &zhipuResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + response, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + usage = zhipuUsage + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + err := resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + return nil, usage +} + +func zhipuHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { + var zhipuResponse ZhipuResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + err = json.Unmarshal(responseBody, &zhipuResponse) + if err != nil { + return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + if !zhipuResponse.Success { + return &OpenAIErrorWithStatusCode{ + OpenAIError: OpenAIError{ + Message: zhipuResponse.Msg, + Type: "zhipu_error", + Param: "", + Code: zhipuResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + fullTextResponse := responseZhipu2OpenAI(&zhipuResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return errorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} diff --git a/controller/relay.go b/controller/relay.go index 81497d81..1926110e 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -1,16 +1,13 @@ package controller import ( - "bufio" - "bytes" - "encoding/json" "fmt" - "github.com/gin-gonic/gin" - "io" "net/http" "one-api/common" - "one-api/model" + "strconv" "strings" + + "github.com/gin-gonic/gin" ) type Message struct { @@ -19,17 +16,51 @@ type Message struct { Name *string `json:"name,omitempty"` } +const ( + RelayModeUnknown = iota + RelayModeChatCompletions + RelayModeCompletions + RelayModeEmbeddings + RelayModeModerations + RelayModeImagesGenerations + RelayModeEdits + RelayModeAudio +) + // https://platform.openai.com/docs/api-reference/chat type GeneralOpenAIRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Prompt string `json:"prompt"` - Stream bool `json:"stream"` - MaxTokens int `json:"max_tokens"` - Temperature float64 `json:"temperature"` - TopP float64 `json:"top_p"` - N int `json:"n"` + Model string `json:"model,omitempty"` + Messages []Message `json:"messages,omitempty"` + Prompt any `json:"prompt,omitempty"` + Stream bool `json:"stream,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + N int `json:"n,omitempty"` + Input any `json:"input,omitempty"` + Instruction string `json:"instruction,omitempty"` + Size string `json:"size,omitempty"` + Functions any `json:"functions,omitempty"` +} + +func (r GeneralOpenAIRequest) ParseInput() []string { + if r.Input == nil { + return nil + } + var input []string + switch r.Input.(type) { + case string: + input = []string{r.Input.(string)} + case []any: + input = make([]string, 0, len(r.Input.([]any))) + for _, item := range r.Input.([]any) { + if str, ok := item.(string); ok { + input = append(input, str) + } + } + } + return input } type ChatRequest struct { @@ -46,6 +77,16 @@ type TextRequest struct { //Stream bool `json:"stream"` } +type ImageRequest struct { + Prompt string `json:"prompt"` + N int `json:"n"` + Size string `json:"size"` +} + +type AudioResponse struct { + Text string `json:"text,omitempty"` +} + type Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` @@ -56,7 +97,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 { @@ -65,32 +106,117 @@ type OpenAIErrorWithStatusCode struct { } type TextResponse struct { - Usage `json:"usage"` - Error OpenAIError `json:"error"` + Choices []OpenAITextResponseChoice `json:"choices"` + Usage `json:"usage"` + Error OpenAIError `json:"error"` } -type StreamResponse struct { +type OpenAITextResponseChoice struct { + Index int `json:"index"` + Message `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type OpenAITextResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Choices []OpenAITextResponseChoice `json:"choices"` + Usage `json:"usage"` +} + +type OpenAIEmbeddingResponseItem struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding []float64 `json:"embedding"` +} + +type OpenAIEmbeddingResponse struct { + Object string `json:"object"` + Data []OpenAIEmbeddingResponseItem `json:"data"` + Model string `json:"model"` + Usage `json:"usage"` +} + +type ImageResponse struct { + Created int `json:"created"` + Data []struct { + Url string `json:"url"` + } +} + +type ChatCompletionsStreamResponseChoice struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +type ChatCompletionsStreamResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []ChatCompletionsStreamResponseChoice `json:"choices"` +} + +type CompletionsStreamResponse struct { Choices []struct { - Delta struct { - Content string `json:"content"` - } `json:"delta"` + 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.HasSuffix(c.Request.URL.Path, "embeddings") { + relayMode = RelayModeEmbeddings + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") { + relayMode = RelayModeModerations + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { + relayMode = RelayModeImagesGenerations + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/edits") { + relayMode = RelayModeEdits + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { + relayMode = RelayModeAudio + } + var err *OpenAIErrorWithStatusCode + switch relayMode { + case RelayModeImagesGenerations: + err = relayImageHelper(c, relayMode) + case RelayModeAudio: + err = relayAudioHelper(c, relayMode) + default: + err = relayTextHelper(c, relayMode) + } if err != nil { - if err.StatusCode == http.StatusTooManyRequests { - err.OpenAIError.Message = "负载已满,请稍后再试,或升级账户以提升服务质量。" + requestId := c.GetString(common.RequestIdKey) + retryTimesStr := c.Query("retry") + retryTimes, _ := strconv.Atoi(retryTimesStr) + if retryTimesStr == "" { + retryTimes = common.RetryTimes + } + if retryTimes > 0 { + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s?retry=%d", c.Request.URL.Path, retryTimes-1)) + } else { + if err.StatusCode == http.StatusTooManyRequests { + err.OpenAIError.Message = "当前分组上游负载已饱和,请稍后再试" + } + err.OpenAIError.Message = common.MessageWithRequestId(err.OpenAIError.Message, requestId) + c.JSON(err.StatusCode, gin.H{ + "error": err.OpenAIError, + }) } - 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.LogError(c.Request.Context(), 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") { + if shouldDisableChannel(&err.OpenAIError, err.StatusCode) { channelId := c.GetInt("channel_id") channelName := c.GetString("channel_name") disableChannel(channelId, channelName, err.Message) @@ -98,241 +224,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 +231,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("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), + Type: "invalid_request_error", + Param: "", + Code: "", + } + c.JSON(http.StatusNotFound, gin.H{ "error": err, }) } diff --git a/controller/token.go b/controller/token.go index 180c4259..8642122c 100644 --- a/controller/token.go +++ b/controller/token.go @@ -109,17 +109,17 @@ func AddToken(c *gin.Context) { }) return } - if len(token.Name) == 0 || len(token.Name) > 20 { + if len(token.Name) > 30 { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "令牌名称长度必须在1-20之间", + "message": "令牌名称过长", }) return } 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, @@ -171,6 +171,13 @@ func UpdateToken(c *gin.Context) { }) return } + if len(token.Name) > 30 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌名称过长", + }) + return + } cleanToken, err := model.GetTokenByIds(token.Id, userId) if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -180,10 +187,10 @@ func UpdateToken(c *gin.Context) { return } 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{ "success": false, - "message": "令牌已过期,无法启用,请先修改令牌过期时间", + "message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期", }) return } diff --git a/controller/user.go b/controller/user.go index b6241d36..8fd10b82 100644 --- a/controller/user.go +++ b/controller/user.go @@ -2,12 +2,14 @@ package controller import ( "encoding/json" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" + "fmt" "net/http" "one-api/common" "one-api/model" "strconv" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" ) type LoginRequest struct { @@ -149,15 +151,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 +233,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 +260,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 +284,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 +359,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 +384,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": "", @@ -442,6 +478,16 @@ func DeleteUser(c *gin.Context) { func DeleteSelf(c *gin.Context) { id := c.GetInt("id") + user, _ := model.GetUserById(id, false) + + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不能删除超级管理员账户", + }) + return + } + err := model.DeleteUserById(id) if err != nil { c.JSON(http.StatusOK, gin.H{ @@ -491,7 +537,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 +701,9 @@ func EmailBind(c *gin.Context) { }) return } + if user.Role == common.RoleRootUser { + common.RootUserEmail = email + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", diff --git a/controller/wechat.go b/controller/wechat.go index 5620e8d3..ff4c9fb6 100644 --- a/controller/wechat.go +++ b/controller/wechat.go @@ -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(), diff --git a/docker-compose.yml b/docker-compose.yml index 0fef5b82..30edb281 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,21 +2,48 @@ 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/oneapi:/data + - ./logs:/app/logs + environment: + - SQL_DSN=oneapi:123456@tcp(db: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 + - db healthcheck: - test: ["CMD-SHELL", "curl -s http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk '{print $2}' | grep 'true'"] + test: [ "CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $2}'" ] interval: 30s timeout: 10s retries: 3 + + redis: + image: redis:latest + container_name: redis + restart: always + + db: + image: mysql:8.2.0 + restart: always + container_name: mysql + volumes: + - ./data/mysql:/var/lib/mysql # 挂载目录,持久化存储 + ports: + - '3306:3306' + environment: + TZ: Asia/Shanghai # 设置时区 + MYSQL_ROOT_PASSWORD: 'OneAPI@justsong' # 设置 root 用户的密码 + MYSQL_USER: oneapi # 创建专用用户 + MYSQL_PASSWORD: '123456' # 设置专用用户密码 + MYSQL_DATABASE: one-api # 自动创建数据库 \ No newline at end of file diff --git a/go.mod b/go.mod index 0280586a..10b78d68 100644 --- a/go.mod +++ b/go.mod @@ -8,49 +8,54 @@ 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/golang-jwt/jwt v3.2.2+incompatible github.com/google/uuid v1.3.0 - github.com/pkoukk/tiktoken-go v0.1.1 - golang.org/x/crypto v0.8.0 + github.com/gorilla/websocket v1.5.0 + github.com/pkoukk/tiktoken-go v0.1.5 + golang.org/x/crypto v0.14.0 gorm.io/driver/mysql v1.4.3 + gorm.io/driver/postgres v1.5.2 gorm.io/driver/sqlite v1.4.3 - gorm.io/gorm v1.24.0 + gorm.io/gorm v1.25.0 ) 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/dlclark/regexp2 v1.10.0 // 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 github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/gomodule/redigo v2.0.0+incompatible // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.3.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.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/text v0.9.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e6bad42f..4865bcaa 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ -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= @@ -14,9 +12,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0= -github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/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 +29,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 +43,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= @@ -52,10 +52,10 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= -github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -65,9 +65,16 @@ github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= +github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= @@ -89,12 +96,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,11 +115,11 @@ 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= +github.com/pkoukk/tiktoken-go v0.1.5 h1:hAlT4dCf6Uk50x8E7HQrddhH3EWMKUN+LArExQQsQx4= +github.com/pkoukk/tiktoken-go v0.1.5/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -128,8 +135,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 +150,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.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 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,14 +162,14 @@ 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.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.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= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -185,9 +193,12 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k= gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= +gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= +gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= -gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74= gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU= +gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/i18n/en.json b/i18n/en.json new file mode 100644 index 00000000..9b2ca4c8 --- /dev/null +++ b/i18n/en.json @@ -0,0 +1,528 @@ +{ + "$%.6f 额度": "$%.6f quota", + "%d 点额度": "%d point quota", + "尚未实现": "Not yet implemented", + "余额不足": "Insufficient balance", + "危险操作": "Hazardous operations", + "输入你的账户名": "Enter your account name", + "确认删除": "Confirm Delete", + "确认绑定": "Confirm Binding", + "您正在删除自己的帐户,将清空所有数据且不可恢复": "You are deleting your account, all data will be cleared and unrecoverable.", + "\"通道「%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", + "

您好,你正在进行%s邮箱验证。

": "

Hello, you are verifying %s email.

", + "

您的验证码为: %s

": "

Your verification code is: %s

", + "

验证码 %d 分钟内有效,如果不是本人操作,请忽略。

": "

The verification code is valid within %d minutes. If it is not your operation, please ignore it.

", + "无效的参数": "Invalid parameter", + "该邮箱地址未注册": "The email address is not registered", + "%s密码重置": "%s Password reset", + "

您好,你正在进行%s密码重置。

": "

Hello, you are resetting %s password.

", + "

点击此处进行密码重置。

": "

Click here to reset your password.

", + "

重置链接 %d 分钟内有效,如果不是本人操作,请忽略。

": "

The reset link is valid within %d minutes. If it is not your operation, please ignore it.

", + "重置链接非法或已过期": "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", + "令牌名称过长": "Token name is too long", + "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期": "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 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,为了不影响您的使用,请及时充值。
充值链接:%s": "%s, the current remaining quota is %d, in order not to affect your use, please recharge in time.
Recharge link: %s", + "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", + " 分钟 ": " m ", + " 小时 ": " h ", + " 天 ": " d ", + " 个月 ": " M ", + " 年 ": " y ", + "未测试": "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", + "在此输入新的公告内容,支持 Markdown & HTML 代码": "Enter the new announcement content here, supports Markdown & HTML code", + "保存公告": "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", + "未登录或登录已过期,请重新登录": "Not logged in or login has expired, please log in again", + "请求次数过多,请稍后再试": "Too many requests, please try again later", + "服务器内部错误,请联系管理员": "Server internal error, please contact the administrator", + "本站仅作演示之用,无服务端": "This site is for demonstration purposes only, no server-side", + "超级管理员未设置充值链接!": "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", + "无法正常连接至服务器": "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", + "请输入额度": "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", + "使用近似的方式估算 token 数以减少计算量": "Estimate the number of tokens in an approximate way to reduce computational load", + "请填写ChannelName和ChannelKey!": "Please fill in the ChannelName and ChannelKey!", + "请至少选择一个Model!": "Please select at least one Model!", + "加载首页内容失败": "Failed to load the homepage content", + "加载关于内容失败": "Failed to load the About content", + "兑换码更新成功!": "Redemption code updated successfully!", + "兑换码创建成功!": "Redemption code created successfully!", + "用户账户创建成功!": "User account created successfully!", + "生成数量": "Generate quantity", + "请输入生成数量": "Please enter the quantity to generate", + "创建新用户账户": "Create new user account", + "渠道更新成功!": "Channel updated successfully!", + "渠道创建成功!": "Channel created successfully!", + "请选择分组": "Please select a group", + "更新兑换码信息": "Update redemption code information", + "创建新的兑换码": "Create a new redemption code", + "请在系统设置页面编辑分组倍率以添加新的分组:": "Please edit the group ratio in the system settings page to add a new group:", + "未找到所请求的页面": "The requested page was not found", + "过期时间格式错误!": "Expiration time format error!", + "请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss,-1 表示无限制": "Please enter the expiration time, the format is yyyy-MM-dd HH:mm:ss, -1 means no limit", + "此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:": "This is optional, it's a JSON text, the key is the model name requested by the user, and the value is the model name to be replaced, for example:", + "此项可选,输入镜像站地址,格式为:": "This is optional, enter the mirror site address, the format is:", + "模型映射": "Model mapping", + "请输入默认 API 版本,例如:2023-03-15-preview,该配置可以被实际的请求查询参数所覆盖": "Please enter the default API version, for example: 2023-03-15-preview, this configuration can be overridden by the actual request query parameters", + "默认": "Default", + "图片演示": "Image demo", + "参数替换为你的部署名称(模型名称中的点会被剔除)": "Replace the parameter with your deployment name (dots in the model name will be removed)", + "模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!", + "取消无限额度": "Cancel unlimited quota", + "取消": "Cancel", + "请输入新的剩余额度": "Please enter the new remaining quota", + "请输入单个兑换码中包含的额度": "Please enter the quota included in a single redemption code", + "请输入用户名": "Please enter username", + "请输入显示名称": "Please enter display name", + "请输入密码": "Please enter password", + "模型部署名称必须和模型名称保持一致": "The model deployment name must be consistent with the model name", + ",因为 One API 会把请求体中的 model": ", because One API will take the model in the request body", + "请输入 AZURE_OPENAI_ENDPOINT": "Please enter AZURE_OPENAI_ENDPOINT", + "请输入自定义渠道的 Base URL": "Please enter the Base URL of the custom channel", + "Homepage URL 填": "Fill in the Homepage URL", + "Authorization callback URL 填": "Fill in the Authorization callback URL", + "请为通道命名": "Please name the channel", + "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:": "This is optional, used to modify the model name in the request body, it's a JSON string, the key is the model name in the request, and the value is the model name to be replaced, for example:", + "模型重定向": "Model redirection", + "请输入渠道对应的鉴权密钥": "Please enter the authentication key corresponding to the channel", + "注意,": "Note that, ", + ",图片演示。": "related image demo.", + "令牌创建成功,请在列表页面点击复制获取令牌!": "Token created successfully, please click copy on the list page to get the token!", + "代理": "Proxy", + "此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com": "This is optional, used to make API calls through the proxy site, please enter the proxy site address, the format is: https://domain.com", + "取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?": "Canceling password login will cause all users (including administrators) who have not bound other login methods to be unable to log in via password, confirm cancel?", + "按照如下格式输入:": "Enter in the following format:", + "模型版本": "Model version", + "请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1": "Please enter the version of the Starfire model, note that it is the version number in the interface address, for example: v2.1", + "点击查看": "click to view", + "请确保已在 Azure 上创建了 gpt-35-turbo 模型,并且 apiVersion 已正确填写!": "Please make sure that the gpt-35-turbo model has been created on Azure, and the apiVersion has been filled in correctly!" +} diff --git a/i18n/translate.py b/i18n/translate.py new file mode 100644 index 00000000..6ba9bc5d --- /dev/null +++ b/i18n/translate.py @@ -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) diff --git a/main.go b/main.go index c8656c7a..88938516 100644 --- a/main.go +++ b/main.go @@ -2,12 +2,12 @@ package main import ( "embed" + "fmt" "github.com/gin-contrib/sessions" "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" @@ -22,54 +22,78 @@ var buildFS embed.FS var indexPage []byte func main() { - common.SetupGinLog() + common.SetupLogger() common.SysLog("One API " + common.Version + " started") if os.Getenv("GIN_MODE") != "debug" { gin.SetMode(gin.ReleaseMode) } + if common.DebugEnabled { + common.SysLog("running in debug mode") + } // 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 os.Getenv("SYNC_FREQUENCY") != "" { - frequency, err := strconv.Atoi(os.Getenv("SYNC_FREQUENCY")) - if err != nil { - common.FatalLog(err) - } - go model.SyncOptions(frequency) + if common.RedisEnabled { + // for compatibility with old versions + common.MemoryCacheEnabled = true } + if common.MemoryCacheEnabled { + common.SysLog("memory cache enabled") + common.SysError(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency)) + model.InitChannelCache() + } + if common.MemoryCacheEnabled { + go model.SyncOptions(common.SyncFrequency) + go model.SyncChannelCache(common.SyncFrequency) + } + 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) + } + if os.Getenv("BATCH_UPDATE_ENABLED") == "true" { + common.BatchUpdateEnabled = true + common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s") + model.InitBatchUpdater() + } + controller.InitTokenEncoders() // Initialize HTTP server - server := gin.Default() + server := gin.New() + server.Use(gin.Recovery()) // This will cause SSE not to work!!! //server.Use(gzip.Gzip(gzip.DefaultCompression)) - server.Use(middleware.CORS()) - + server.Use(middleware.RequestId()) + middleware.SetUpLogger(server) // Initialize session store - if common.RedisEnabled { - opt := common.ParseRedisOption() - 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)) - } + store := cookie.NewStore([]byte(common.SessionSecret)) + server.Use(sessions.Sessions("session", store)) router.SetRouter(server, buildFS, indexPage) var port = os.Getenv("PORT") @@ -78,6 +102,6 @@ func main() { } err = server.Run(":" + port) if err != nil { - log.Println(err) + common.FatalLog("failed to start HTTP server: " + err.Error()) } } diff --git a/middleware/auth.go b/middleware/auth.go index f652f058..b0803612 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -91,27 +91,21 @@ func TokenAuth() func(c *gin.Context) { key = parts[0] token, err := model.ValidateUserToken(key) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "error": gin.H{ - "message": err.Error(), - "type": "one_api_error", - }, - }) - c.Abort() + abortWithMessage(c, http.StatusUnauthorized, err.Error()) return } - if !model.IsUserEnabled(token.UserId) { - c.JSON(http.StatusOK, gin.H{ - "error": gin.H{ - "message": "用户已被封禁", - "type": "one_api_error", - }, - }) - c.Abort() + userEnabled, err := model.CacheIsUserEnabled(token.UserId) + if err != nil { + abortWithMessage(c, http.StatusInternalServerError, err.Error()) + return + } + if !userEnabled { + abortWithMessage(c, http.StatusForbidden, "用户已被封禁") return } 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,13 +116,7 @@ func TokenAuth() func(c *gin.Context) { if model.IsAdmin(token.UserId) { c.Set("channelId", parts[1]) } else { - c.JSON(http.StatusOK, gin.H{ - "error": gin.H{ - "message": "普通用户不支持指定渠道", - "type": "one_api_error", - }, - }) - c.Abort() + abortWithMessage(c, http.StatusForbidden, "普通用户不支持指定渠道") return } } diff --git a/middleware/cors.go b/middleware/cors.go index 0197ef02..d2a109ab 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -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) } diff --git a/middleware/distributor.go b/middleware/distributor.go index 357849e7..d80945fc 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -2,74 +2,96 @@ package middleware import ( "fmt" - "github.com/gin-gonic/gin" "net/http" "one-api/common" "one-api/model" "strconv" + "strings" + + "github.com/gin-gonic/gin" ) +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{ - "error": gin.H{ - "message": "无效的渠道 ID", - "type": "one_api_error", - }, - }) - c.Abort() + abortWithMessage(c, http.StatusBadRequest, "无效的渠道 Id") return } channel, err = model.GetChannelById(id, true) if err != nil { - c.JSON(200, gin.H{ - "error": gin.H{ - "message": "无效的渠道 ID", - "type": "one_api_error", - }, - }) - c.Abort() + abortWithMessage(c, http.StatusBadRequest, "无效的渠道 Id") return } if channel.Status != common.ChannelStatusEnabled { - c.JSON(200, gin.H{ - "error": gin.H{ - "message": "该渠道已被禁用", - "type": "one_api_error", - }, - }) - c.Abort() + abortWithMessage(c, http.StatusForbidden, "该渠道已被禁用") return } } else { // Select a channel for the user + var modelRequest ModelRequest var err error - channel, err = model.GetRandomChannel() + if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { + err = common.UnmarshalBodyReusable(c, &modelRequest) + } if err != nil { - c.JSON(200, gin.H{ - "error": gin.H{ - "message": "无可用渠道", - "type": "one_api_error", - }, - }) - c.Abort() + abortWithMessage(c, http.StatusBadRequest, "无效的请求") + return + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") { + if modelRequest.Model == "" { + modelRequest.Model = "text-moderation-stable" + } + } + if strings.HasSuffix(c.Request.URL.Path, "embeddings") { + if modelRequest.Model == "" { + modelRequest.Model = c.Param("model") + } + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { + if modelRequest.Model == "" { + modelRequest.Model = "dall-e" + } + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { + if modelRequest.Model == "" { + modelRequest.Model = "whisper-1" + } + } + channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model) + if err != nil { + message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", userGroup, modelRequest.Model) + if channel != nil { + common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) + message = "数据库一致性已被破坏,请联系管理员" + } + abortWithMessage(c, http.StatusServiceUnavailable, message) return } } c.Set("channel", channel.Type) c.Set("channel_id", channel.Id) c.Set("channel_name", channel.Name) + c.Set("model_mapping", channel.GetModelMapping()) 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.GetBaseURL()) + switch channel.Type { + case common.ChannelTypeAzure: + c.Set("api_version", channel.Other) + case common.ChannelTypeXunfei: + c.Set("api_version", channel.Other) + case common.ChannelTypeAIProxyLibrary: + c.Set("library_id", channel.Other) } c.Next() } diff --git a/middleware/logger.go b/middleware/logger.go new file mode 100644 index 00000000..02f2e0a9 --- /dev/null +++ b/middleware/logger.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "fmt" + "github.com/gin-gonic/gin" + "one-api/common" +) + +func SetUpLogger(server *gin.Engine) { + server.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + var requestID string + if param.Keys != nil { + requestID = param.Keys[common.RequestIdKey].(string) + } + return fmt.Sprintf("[GIN] %s | %s | %3d | %13v | %15s | %7s %s\n", + param.TimeStamp.Format("2006/01/02 - 15:04:05"), + requestID, + param.StatusCode, + param.Latency, + param.ClientIP, + param.Method, + param.Path, + ) + })) +} diff --git a/middleware/request-id.go b/middleware/request-id.go new file mode 100644 index 00000000..e623be7a --- /dev/null +++ b/middleware/request-id.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "context" + "github.com/gin-gonic/gin" + "one-api/common" +) + +func RequestId() func(c *gin.Context) { + return func(c *gin.Context) { + id := common.GetTimeString() + common.GetRandomString(8) + c.Set(common.RequestIdKey, id) + ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id) + c.Request = c.Request.WithContext(ctx) + c.Header(common.RequestIdKey, id) + c.Next() + } +} diff --git a/middleware/utils.go b/middleware/utils.go new file mode 100644 index 00000000..536125cc --- /dev/null +++ b/middleware/utils.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "one-api/common" +) + +func abortWithMessage(c *gin.Context, statusCode int, message string) { + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)), + "type": "one_api_error", + }, + }) + c.Abort() + common.LogError(c.Request.Context(), message) +} diff --git a/model/ability.go b/model/ability.go new file mode 100644 index 00000000..3da83be8 --- /dev/null +++ b/model/ability.go @@ -0,0 +1,84 @@ +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"` + Priority *int64 `json:"priority" gorm:"bigint;default:0;index"` +} + +func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) { + ability := Ability{} + groupCol := "`group`" + trueVal := "1" + if common.UsingPostgreSQL { + groupCol = `"group"` + trueVal = "true" + } + + var err error = nil + maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(groupCol+" = ? and model = ? and enabled = "+trueVal, group, model) + channelQuery := DB.Where(groupCol+" = ? and model = ? and enabled = "+trueVal+" and priority = (?)", group, model, maxPrioritySubQuery) + if common.UsingSQLite || common.UsingPostgreSQL { + err = channelQuery.Order("RANDOM()").First(&ability).Error + } else { + err = channelQuery.Order("RAND()").First(&ability).Error + } + if err != nil { + return nil, err + } + channel := Channel{} + channel.Id = ability.ChannelId + 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, + Priority: channel.Priority, + } + 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 +} diff --git a/model/cache.go b/model/cache.go new file mode 100644 index 00000000..c6d0c70a --- /dev/null +++ b/model/cache.go @@ -0,0 +1,215 @@ +package model + +import ( + "encoding/json" + "errors" + "fmt" + "math/rand" + "one-api/common" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +var ( + TokenCacheSeconds = common.SyncFrequency + UserId2GroupCacheSeconds = common.SyncFrequency + UserId2QuotaCacheSeconds = common.SyncFrequency + UserId2StatusCacheSeconds = common.SyncFrequency +) + +func CacheGetTokenByKey(key string) (*Token, error) { + keyCol := "`key`" + if common.UsingPostgreSQL { + keyCol = `"key"` + } + var token Token + if !common.RedisEnabled { + err := DB.Where(keyCol+" = ?", key).First(&token).Error + return &token, err + } + tokenObjectString, err := common.RedisGet(fmt.Sprintf("token:%s", key)) + if err != nil { + err := DB.Where(keyCol+" = ?", 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), time.Duration(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, time.Duration(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), time.Duration(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 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), time.Duration(UserId2QuotaCacheSeconds)*time.Second) + return err +} + +func CacheDecreaseUserQuota(id int, quota int) error { + if !common.RedisEnabled { + return nil + } + err := common.RedisDecrease(fmt.Sprintf("user_quota:%d", id), int64(quota)) + return err +} + +func CacheIsUserEnabled(userId int) (bool, error) { + if !common.RedisEnabled { + return IsUserEnabled(userId) + } + enabled, err := common.RedisGet(fmt.Sprintf("user_enabled:%d", userId)) + if err == nil { + return enabled == "1", nil + } + + userEnabled, err := IsUserEnabled(userId) + if err != nil { + return false, err + } + enabled = "0" + if userEnabled { + enabled = "1" + } + err = common.RedisSet(fmt.Sprintf("user_enabled:%d", userId), enabled, time.Duration(UserId2StatusCacheSeconds)*time.Second) + if err != nil { + common.SysError("Redis set user enabled error: " + err.Error()) + } + return userEnabled, err +} + +var group2model2channels map[string]map[string][]*Channel +var channelSyncLock sync.RWMutex + +func InitChannelCache() { + newChannelId2channel := make(map[int]*Channel) + var channels []*Channel + DB.Where("status = ?", common.ChannelStatusEnabled).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) + } + } + } + + // sort by priority + for group, model2channels := range newGroup2model2channels { + for model, channels := range model2channels { + sort.Slice(channels, func(i, j int) bool { + return channels[i].GetPriority() > channels[j].GetPriority() + }) + newGroup2model2channels[group][model] = channels + } + } + + 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.MemoryCacheEnabled { + return GetRandomSatisfiedChannel(group, model) + } + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + channels := group2model2channels[group][model] + if len(channels) == 0 { + return nil, errors.New("channel not found") + } + endIdx := len(channels) + // choose by priority + firstChannel := channels[0] + if firstChannel.GetPriority() > 0 { + for i := range channels { + if channels[i].GetPriority() != firstChannel.GetPriority() { + endIdx = i + break + } + } + } + idx := rand.Intn(endIdx) + return channels[idx], nil +} diff --git a/model/channel.go b/model/channel.go index 35d65827..7e7b42e6 100644 --- a/model/channel.go +++ b/model/channel.go @@ -1,24 +1,29 @@ 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"` + Weight *uint `json:"weight" gorm:"default:0"` CreatedTime int64 `json:"created_time" gorm:"bigint"` TestTime int64 `json:"test_time" gorm:"bigint"` ResponseTime int `json:"response_time"` // in milliseconds - BaseURL string `json:"base_url" gorm:"column:base_url"` + BaseURL *string `json:"base_url" gorm:"column:base_url;default:''"` 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"` + ModelMapping *string `json:"model_mapping" gorm:"type:varchar(1024);default:''"` + Priority *int64 `json:"priority" gorm:"bigint;default:0"` } func GetAllChannels(startIdx int, num int, selectAll bool) ([]*Channel, error) { @@ -33,7 +38,11 @@ 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 + keyCol := "`key`" + if common.UsingPostgreSQL { + keyCol = `"key"` + } + err = DB.Omit("key").Where("id = ? or name LIKE ? or "+keyCol+" = ?", common.String2Int(keyword), keyword+"%", keyword).Find(&channels).Error return channels, err } @@ -48,33 +57,60 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) { return &channel, err } -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 - } else { - err = DB.Where("status = ?", common.ChannelStatusEnabled).Order("RAND()").Limit(1).First(&channel).Error - } - return &channel, err -} - 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) GetPriority() int64 { + if channel.Priority == nil { + return 0 + } + return *channel.Priority +} + +func (channel *Channel) GetBaseURL() string { + if channel.BaseURL == nil { + return "" + } + return *channel.BaseURL +} + +func (channel *Channel) GetModelMapping() string { + if channel.ModelMapping == nil { + return "" + } + return *channel.ModelMapping } 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 +137,45 @@ 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) { + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeChannelUsedQuota, id, quota) + return + } + updateChannelUsedQuota(id, quota) +} + +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()) + } +} + +func DeleteChannelByStatus(status int64) (int64, error) { + result := DB.Where("status = ?", status).Delete(&Channel{}) + return result.RowsAffected, result.Error +} + +func DeleteDisabledChannel() (int64, error) { + result := DB.Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).Delete(&Channel{}) + return result.RowsAffected, result.Error +} diff --git a/model/log.go b/model/log.go new file mode 100644 index 00000000..3d3ffae3 --- /dev/null +++ b/model/log.go @@ -0,0 +1,184 @@ +package model + +import ( + "context" + "fmt" + "gorm.io/gorm" + "one-api/common" +) + +type Log struct { + Id int `json:"id;index:idx_created_at_id,priority:1"` + UserId int `json:"user_id" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"` + Type int `json:"type" gorm:"index:idx_created_at_type"` + Content string `json:"content"` + Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"` + TokenName string `json:"token_name" gorm:"index;default:''"` + ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` + Quota int `json:"quota" gorm:"default:0"` + PromptTokens int `json:"prompt_tokens" gorm:"default:0"` + CompletionTokens int `json:"completion_tokens" gorm:"default:0"` + ChannelId int `json:"channel" gorm:"index"` +} + +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, + Username: GetUsernameById(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 RecordConsumeLog(ctx context.Context, userId int, channelId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int, content string) { + common.LogInfo(ctx, fmt.Sprintf("record consume log: userId=%d, channelId=%d, promptTokens=%d, completionTokens=%d, modelName=%s, tokenName=%s, quota=%d, content=%s", userId, channelId, promptTokens, completionTokens, modelName, tokenName, quota, content)) + 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, + ChannelId: channelId, + } + err := DB.Create(log).Error + if err != nil { + common.LogError(ctx, "failed to record log: "+err.Error()) + } +} + +func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int) (logs []*Log, err error) { + var tx *gorm.DB + if logType == LogTypeUnknown { + tx = DB + } else { + 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) + } + if channel != 0 { + tx = tx.Where("channel_id = ?", channel) + } + err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error + return logs, err +} + +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 + if logType == LogTypeUnknown { + tx = DB.Where("user_id = ?", userId) + } else { + 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 + 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 +} + +func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int) (quota int) { + tx := DB.Table("logs").Select("ifnull(sum(quota),0)") + 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) + } + if channel != 0 { + tx = tx.Where("channel_id = ?", channel) + } + tx.Where("type = ?", LogTypeConsume).Scan("a) + return quota +} + +func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) { + tx := DB.Table("logs").Select("ifnull(sum(prompt_tokens),0) + ifnull(sum(completion_tokens),0)") + 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 +} + +func DeleteOldLog(targetTimestamp int64) (int64, error) { + result := DB.Where("created_at < ?", targetTimestamp).Delete(&Log{}) + return result.RowsAffected, result.Error +} diff --git a/model/main.go b/model/main.go index 3f6fafbf..08182634 100644 --- a/model/main.go +++ b/model/main.go @@ -2,10 +2,13 @@ package model import ( "gorm.io/driver/mysql" + "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "one-api/common" "os" + "strings" + "time" ) var DB *gorm.DB @@ -33,29 +36,54 @@ func createRootAccountIfNeed() error { return nil } -func CountTable(tableName string) (num int64) { - DB.Table(tableName).Count(&num) - return +func chooseDB() (*gorm.DB, error) { + if os.Getenv("SQL_DSN") != "" { + dsn := os.Getenv("SQL_DSN") + if strings.HasPrefix(dsn, "postgres://") { + // Use PostgreSQL + common.SysLog("using PostgreSQL as database") + common.UsingPostgreSQL = true + return gorm.Open(postgres.New(postgres.Config{ + DSN: dsn, + PreferSimpleProtocol: true, // disables implicit prepared statement usage + }), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) + } + // Use MySQL + common.SysLog("using MySQL as database") + return gorm.Open(mysql.Open(dsn), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) + } + // Use SQLite + common.SysLog("SQL_DSN not set, using SQLite as database") + common.UsingSQLite = true + return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) } func InitDB() (err error) { - var db *gorm.DB - if os.Getenv("SQL_DSN") != "" { - // Use MySQL - db, err = gorm.Open(mysql.Open(os.Getenv("SQL_DSN")), &gorm.Config{ - PrepareStmt: true, // precompile SQL - }) - } else { - // Use SQLite - 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") - } + db, err := chooseDB() if err == nil { + if common.DebugEnabled { + db = db.Debug() + } DB = db - err := db.AutoMigrate(&Channel{}) + sqlDB, err := DB.DB() + if err != nil { + return err + } + sqlDB.SetMaxIdleConns(common.GetOrDefault("SQL_MAX_IDLE_CONNS", 100)) + sqlDB.SetMaxOpenConns(common.GetOrDefault("SQL_MAX_OPEN_CONNS", 1000)) + sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetOrDefault("SQL_MAX_LIFETIME", 60))) + + if !common.IsMasterNode { + return nil + } + common.SysLog("database migration started") + err = db.AutoMigrate(&Channel{}) if err != nil { return err } @@ -75,6 +103,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 { diff --git a/model/option.go b/model/option.go index 5f7ad36c..4ef4d260 100644 --- a/model/option.go +++ b/model/option.go @@ -34,7 +34,13 @@ 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["ApproximateTokenEnabled"] = strconv.FormatBool(common.ApproximateTokenEnabled) + 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["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled) + common.OptionMap["EmailDomainWhitelist"] = strings.Join(common.EmailDomainWhitelist, ",") common.OptionMap["SMTPServer"] = "" common.OptionMap["SMTPFrom"] = "" common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort) @@ -55,10 +61,16 @@ 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.OptionMap["RetryTimes"] = strconv.Itoa(common.RetryTimes) common.OptionMapRWMutex.Unlock() loadOptionsFromDatabase() } @@ -68,7 +80,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 +88,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() } } @@ -131,11 +143,23 @@ func updateOptionMap(key string, value string) (err error) { common.TurnstileCheckEnabled = boolValue case "RegisterEnabled": common.RegisterEnabled = boolValue + case "EmailDomainRestrictionEnabled": + common.EmailDomainRestrictionEnabled = boolValue case "AutomaticDisableChannelEnabled": common.AutomaticDisableChannelEnabled = boolValue + case "ApproximateTokenEnabled": + common.ApproximateTokenEnabled = boolValue + case "LogConsumeEnabled": + common.LogConsumeEnabled = boolValue + case "DisplayInCurrencyEnabled": + common.DisplayInCurrencyEnabled = boolValue + case "DisplayTokenStatEnabled": + common.DisplayTokenStatEnabled = boolValue } } switch key { + case "EmailDomainWhitelist": + common.EmailDomainWhitelist = strings.Split(value, ",") case "SMTPServer": common.SMTPServer = value case "SMTPPort": @@ -171,16 +195,28 @@ 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 "RetryTimes": + common.RetryTimes, _ = 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 } diff --git a/model/redemption.go b/model/redemption.go index b731acf7..f16412b5 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -2,7 +2,8 @@ package model import ( "errors" - _ "gorm.io/driver/sqlite" + "fmt" + "gorm.io/gorm" "one-api/common" ) @@ -48,25 +49,33 @@ func Redeem(key string, userId int) (quota int, err error) { return 0, errors.New("无效的 user id") } redemption := &Redemption{} - err = DB.Where("`key` = ?", key).First(redemption).Error - if err != nil { - return 0, errors.New("无效的兑换码") + + keyCol := "`key`" + if common.UsingPostgreSQL { + keyCol = `"key"` } - if redemption.Status != common.RedemptionCodeStatusEnabled { - return 0, errors.New("该兑换码已被使用") - } - err = IncreaseUserQuota(userId, redemption.Quota) - if err != nil { - return 0, err - } - go func() { + + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error + if err != nil { + return errors.New("无效的兑换码") + } + if redemption.Status != common.RedemptionCodeStatusEnabled { + return errors.New("该兑换码已被使用") + } + err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error + if err != nil { + return err + } redemption.RedeemedTime = common.GetTimestamp() redemption.Status = common.RedemptionCodeStatusUsed - err := redemption.SelectUpdate() - if err != nil { - common.SysError("更新兑换码状态失败:" + err.Error()) - } - }() + err = tx.Save(redemption).Error + return err + }) + if err != nil { + return 0, errors.New("兑换失败," + err.Error()) + } + RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota))) return redemption.Quota, nil } @@ -84,7 +93,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 } diff --git a/model/token.go b/model/token.go index ef2d914b..0fa984d3 100644 --- a/model/token.go +++ b/model/token.go @@ -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,48 @@ 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.TokenStatusExhausted { + return nil, errors.New("该令牌额度已用尽") + } else if token.Status == common.TokenStatusExpired { + return nil, errors.New("该令牌已过期") + } 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()) + if !common.RedisEnabled { + token.Status = common.TokenStatusExpired + err := token.SelectUpdate() + if err != nil { + 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()) + if !common.RedisEnabled { + // in this case, we can make sure the token is exhausted + token.Status = common.TokenStatusExhausted + err := token.SelectUpdate() + if err != nil { + 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()) - } - }() return token, nil } - return nil, errors.New("无效的 token") + return nil, errors.New("无效的令牌") } func GetTokenByIds(id int, userId int) (*Token, error) { @@ -132,7 +134,21 @@ 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 + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeTokenQuota, id, quota) + return nil + } + return increaseTokenQuota(id, quota) +} + +func increaseTokenQuota(id int, quota int) (err 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), + "accessed_time": common.GetTimestamp(), + }, + ).Error return err } @@ -140,7 +156,21 @@ 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 + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeTokenQuota, id, -quota) + return nil + } + return decreaseTokenQuota(id, quota) +} + +func decreaseTokenQuota(id int, quota int) (err 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), + "accessed_time": common.GetTimestamp(), + }, + ).Error return err } @@ -168,7 +198,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 +209,7 @@ func PreConsumeTokenQuota(tokenId int, quota int) (err error) { err = common.SendEmail(prompt, email, fmt.Sprintf("%s,当前剩余额度为 %d,为了不影响您的使用,请及时充值。
充值链接:%s", prompt, userQuota, topUpLink, topUpLink)) if err != nil { - common.SysError("发送邮件失败:" + err.Error()) + common.SysError("failed to send email" + err.Error()) } } }() diff --git a/model/user.go b/model/user.go index 2ca0d6a4..7844eb6a 100644 --- a/model/user.go +++ b/model/user.go @@ -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,23 +220,22 @@ 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 } -func IsUserEnabled(userId int) bool { +func IsUserEnabled(userId int) (bool, error) { if userId == 0 { - return false + return false, errors.New("user id is empty") } var user User err := DB.Where("id = ?", userId).Select("status").Find(&user).Error if err != nil { - common.SysError("No such user " + err.Error()) - return false + return false, err } - return user.Status == common.UserStatusEnabled + return user.Status == common.UserStatusEnabled, nil } func ValidateAccessToken(token string) (user *User) { @@ -224,15 +255,38 @@ 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("a).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) { + groupCol := "`group`" + if common.UsingPostgreSQL { + groupCol = `"group"` + } + + err = DB.Model(&User{}).Where("id = ?", id).Select(groupCol).Find(&group).Error + return group, err +} + func IncreaseUserQuota(id int, quota int) (err error) { if quota < 0 { return errors.New("quota 不能为负数!") } + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUserQuota, id, quota) + return nil + } + return increaseUserQuota(id, quota) +} + +func increaseUserQuota(id int, quota int) (err error) { err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error return err } @@ -241,6 +295,14 @@ func DecreaseUserQuota(id int, quota int) (err error) { if quota < 0 { return errors.New("quota 不能为负数!") } + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUserQuota, id, -quota) + return nil + } + return decreaseUserQuota(id, quota) +} + +func decreaseUserQuota(id int, quota int) (err error) { err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error return err } @@ -249,3 +311,47 @@ func GetRootUserEmail() (email string) { DB.Model(&User{}).Where("role = ?", common.RoleRootUser).Select("email").Find(&email) return email } + +func UpdateUserUsedQuotaAndRequestCount(id int, quota int) { + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUsedQuota, id, quota) + addNewRecord(BatchUpdateTypeRequestCount, id, 1) + return + } + updateUserUsedQuotaAndRequestCount(id, quota, 1) +} + +func updateUserUsedQuotaAndRequestCount(id int, quota int, count 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 + ?", count), + }, + ).Error + if err != nil { + common.SysError("failed to update user used quota and request count: " + err.Error()) + } +} + +func updateUserUsedQuota(id int, quota int) { + err := DB.Model(&User{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "used_quota": gorm.Expr("used_quota + ?", quota), + }, + ).Error + if err != nil { + common.SysError("failed to update user used quota: " + err.Error()) + } +} + +func updateUserRequestCount(id int, count int) { + err := DB.Model(&User{}).Where("id = ?", id).Update("request_count", gorm.Expr("request_count + ?", count)).Error + if err != nil { + common.SysError("failed to update user request count: " + err.Error()) + } +} + +func GetUsernameById(id int) (username string) { + DB.Model(&User{}).Where("id = ?", id).Select("username").Find(&username) + return username +} diff --git a/model/utils.go b/model/utils.go new file mode 100644 index 00000000..1c28340b --- /dev/null +++ b/model/utils.go @@ -0,0 +1,77 @@ +package model + +import ( + "one-api/common" + "sync" + "time" +) + +const ( + BatchUpdateTypeUserQuota = iota + BatchUpdateTypeTokenQuota + BatchUpdateTypeUsedQuota + BatchUpdateTypeChannelUsedQuota + BatchUpdateTypeRequestCount + BatchUpdateTypeCount // if you add a new type, you need to add a new map and a new lock +) + +var batchUpdateStores []map[int]int +var batchUpdateLocks []sync.Mutex + +func init() { + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateStores = append(batchUpdateStores, make(map[int]int)) + batchUpdateLocks = append(batchUpdateLocks, sync.Mutex{}) + } +} + +func InitBatchUpdater() { + go func() { + for { + time.Sleep(time.Duration(common.BatchUpdateInterval) * time.Second) + batchUpdate() + } + }() +} + +func addNewRecord(type_ int, id int, value int) { + batchUpdateLocks[type_].Lock() + defer batchUpdateLocks[type_].Unlock() + if _, ok := batchUpdateStores[type_][id]; !ok { + batchUpdateStores[type_][id] = value + } else { + batchUpdateStores[type_][id] += value + } +} + +func batchUpdate() { + common.SysLog("batch update started") + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateLocks[i].Lock() + store := batchUpdateStores[i] + batchUpdateStores[i] = make(map[int]int) + batchUpdateLocks[i].Unlock() + // TODO: maybe we can combine updates with same key? + for key, value := range store { + switch i { + case BatchUpdateTypeUserQuota: + err := increaseUserQuota(key, value) + if err != nil { + common.SysError("failed to batch update user quota: " + err.Error()) + } + case BatchUpdateTypeTokenQuota: + err := increaseTokenQuota(key, value) + if err != nil { + common.SysError("failed to batch update token quota: " + err.Error()) + } + case BatchUpdateTypeUsedQuota: + updateUserUsedQuota(key, value) + case BatchUpdateTypeRequestCount: + updateUserRequestCount(key, value) + case BatchUpdateTypeChannelUsedQuota: + updateChannelUsedQuota(key, value) + } + } + } + common.SysLog("batch update finished") +} diff --git a/one-api.service b/one-api.service index 174afe36..17e236bc 100644 --- a/one-api.service +++ b/one-api.service @@ -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 diff --git a/router/api-router.go b/router/api-router.go index 9ca2226a..da3f9e61 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -1,10 +1,11 @@ package router import ( - "github.com/gin-contrib/gzip" - "github.com/gin-gonic/gin" "one-api/controller" "one-api/middleware" + + "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" ) func SetApiRouter(router *gin.Engine) { @@ -20,6 +21,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth) + apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode) apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind) apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind) @@ -37,6 +39,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 +66,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) @@ -70,6 +74,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance) channelRoute.POST("/", controller.AddChannel) channelRoute.PUT("/", controller.UpdateChannel) + channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel) channelRoute.DELETE("/:id", controller.DeleteChannel) } tokenRoute := apiRouter.Group("/token") @@ -92,5 +97,18 @@ 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.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs) + logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) + logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) + 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) + } } } diff --git a/router/main.go b/router/main.go index 2cb50364..b8ac4055 100644 --- a/router/main.go +++ b/router/main.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/gin-gonic/gin" "net/http" + "one-api/common" "os" "strings" ) @@ -14,6 +15,10 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { SetDashboardRouter(router) SetRelayRouter(router) frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") + if common.IsMasterNode && frontendBaseUrl != "" { + frontendBaseUrl = "" + common.SysLog("FRONTEND_BASE_URL is ignored on master node") + } if frontendBaseUrl == "" { SetWebRouter(router, buildFS, indexPage) } else { diff --git a/router/relay-router.go b/router/relay-router.go index 46c37a89..e84f02db 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -1,27 +1,34 @@ package router import ( - "github.com/gin-gonic/gin" "one-api/controller" "one-api/middleware" + + "github.com/gin-gonic/gin" ) func SetRelayRouter(router *gin.Engine) { + router.Use(middleware.CORS()) // 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) + relayV1Router.POST("/edits", controller.Relay) + relayV1Router.POST("/images/generations", controller.Relay) relayV1Router.POST("/images/edits", controller.RelayNotImplemented) relayV1Router.POST("/images/variations", controller.RelayNotImplemented) relayV1Router.POST("/embeddings", controller.Relay) - relayV1Router.POST("/audio/transcriptions", controller.RelayNotImplemented) - relayV1Router.POST("/audio/translations", controller.RelayNotImplemented) + relayV1Router.POST("/engines/:model/embeddings", controller.Relay) + relayV1Router.POST("/audio/transcriptions", controller.Relay) + relayV1Router.POST("/audio/translations", controller.Relay) relayV1Router.GET("/files", controller.RelayNotImplemented) relayV1Router.POST("/files", controller.RelayNotImplemented) relayV1Router.DELETE("/files/:id", controller.RelayNotImplemented) @@ -33,6 +40,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) } } diff --git a/router/web-router.go b/router/web-router.go index 8f6d1ac4..8f9c18a2 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -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") || strings.HasPrefix(c.Request.RequestURI, "/api") { + controller.RelayNotFound(c) + return + } c.Header("Cache-Control", "no-cache") c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage) }) diff --git a/web/public/index.html b/web/public/index.html index b7134f53..b8e324d2 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -7,7 +7,7 @@ One API diff --git a/web/src/App.js b/web/src/App.js index b2699858..13c884dc 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -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() { } /> + + + + } + /> } /> - + }> + + + } + /> + + } /> ); } diff --git a/web/src/components/ChannelsTable.js b/web/src/components/ChannelsTable.js index a0a0f5dd..d44ea2d7 100644 --- a/web/src/components/ChannelsTable.js +++ b/web/src/components/ChannelsTable.js @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { Button, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; +import { Button, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react'; import { Link } from 'react-router-dom'; -import { API, showError, showInfo, showSuccess, timestamp2string } from '../helpers'; +import { API, setPromptShown, shouldShowPrompt, showError, showInfo, showSuccess, timestamp2string } from '../helpers'; import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; +import { renderGroup, renderNumber } from '../helpers/render'; function renderTimestamp(timestamp) { return ( @@ -23,7 +24,28 @@ function renderType(type) { } type2label[0] = { value: 0, text: '未知类型', color: 'grey' }; } - return ; + return ; +} + +function renderBalance(type, balance) { + switch (type) { + case 1: // OpenAI + return ${balance.toFixed(2)}; + case 4: // CloseAI + return ¥{balance.toFixed(2)}; + case 8: // 自定义 + return ${balance.toFixed(2)}; + case 5: // OpenAI-SB + return ¥{(balance / 10000).toFixed(2)}; + case 10: // AI Proxy + return {renderNumber(balance)}; + case 12: // API2GPT + return ¥{balance.toFixed(2)}; + case 13: // AIGC2D + return {renderNumber(balance)}; + default: + return 不支持; + } } const ChannelsTable = () => { @@ -33,6 +55,7 @@ const ChannelsTable = () => { const [searchKeyword, setSearchKeyword] = useState(''); const [searching, setSearching] = useState(false); const [updatingBalance, setUpdatingBalance] = useState(false); + const [showPrompt, setShowPrompt] = useState(shouldShowPrompt("channel-test")); const loadChannels = async (startIdx) => { const res = await API.get(`/api/channel/?p=${startIdx}`); @@ -41,8 +64,8 @@ const ChannelsTable = () => { if (startIdx === 0) { setChannels(data); } else { - let newChannels = channels; - newChannels.push(...data); + let newChannels = [...channels]; + newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); setChannels(newChannels); } } else { @@ -63,7 +86,7 @@ const ChannelsTable = () => { const refresh = async () => { setLoading(true); - await loadChannels(0); + await loadChannels(activePage - 1); }; useEffect(() => { @@ -74,7 +97,7 @@ const ChannelsTable = () => { }); }, []); - const manageChannel = async (id, action, idx) => { + const manageChannel = async (id, action, idx, value) => { let data = { id }; let res; switch (action) { @@ -89,6 +112,23 @@ const ChannelsTable = () => { data.status = 2; res = await API.put('/api/channel/', data); break; + case 'priority': + if (value === '') { + return; + } + data.priority = parseInt(value); + res = await API.put('/api/channel/', data); + break; + case 'weight': + if (value === '') { + return; + } + data.weight = parseInt(value); + if (data.weight < 0) { + data.weight = 0; + } + res = await API.put('/api/channel/', data); + break; } const { success, message } = res.data; if (success) { @@ -113,9 +153,23 @@ const ChannelsTable = () => { return ; case 2: return ( - + + 已禁用 + } + content='本渠道被手动禁用' + basic + /> + ); + case 3: + return ( + + 已禁用 + } + content='本渠道被程序自动禁用' + basic + /> ); default: return ( @@ -186,6 +240,17 @@ const ChannelsTable = () => { } }; + const deleteAllDisabledChannels = async () => { + const res = await API.delete(`/api/channel/disabled`); + const { success, message, data } = res.data; + if (success) { + showSuccess(`已删除所有禁用渠道,共计 ${data} 个`); + await refresh(); + } else { + showError(message); + } + }; + const updateChannelBalance = async (id, name, idx) => { const res = await API.get(`/api/channel/update_balance/${id}/`); const { success, message, balance } = res.data; @@ -222,7 +287,13 @@ const ChannelsTable = () => { setLoading(true); let sortedChannels = [...channels]; sortedChannels.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); + if (!isNaN(a[key])) { + // If the value is numeric, subtract to sort + return a[key] - b[key]; + } else { + // If the value is not numeric, sort as strings + return ('' + a[key]).localeCompare(b[key]); + } }); if (sortedChannels[0].id === channels[0].id) { sortedChannels.reverse(); @@ -231,6 +302,7 @@ const ChannelsTable = () => { setLoading(false); }; + return ( <>
@@ -238,14 +310,26 @@ const ChannelsTable = () => { icon='search' fluid iconPosition='left' - placeholder='搜索渠道的 ID 和名称 ...' + placeholder='搜索渠道的 ID,名称和密钥 ...' value={searchKeyword} loading={searching} onChange={handleKeywordChange} />
+ { + showPrompt && ( + { + setShowPrompt(false); + setPromptShown("channel-test"); + }}> + 当前版本测试是通过按照 OpenAI API 格式使用 gpt-3.5-turbo + 模型进行非流式请求实现的,因此测试报错并不一定代表通道不可用,该功能后续会修复。 - + 另外,OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型,请点击余额进行刷新。 + + ) + } +
{ > 名称 + { + sortChannel('group'); + }} + > + 分组 + { @@ -296,6 +388,14 @@ const ChannelsTable = () => { > 余额 + { + sortChannel('priority'); + }} + > + 优先级 + 操作 @@ -312,6 +412,7 @@ const ChannelsTable = () => { {channel.id} {channel.name ? channel.name : '无'} + {renderGroup(channel.group)} {renderType(channel.type)} {renderStatus(channel.status)} @@ -324,9 +425,28 @@ const ChannelsTable = () => { ${channel.balance.toFixed(2)}} + trigger={ { + updateChannelBalance(channel.id, channel.name, idx); + }} style={{ cursor: 'pointer' }}> + {renderBalance(channel.type, channel.balance)} + } + content='点击更新' + basic + /> + + + { + manageChannel( + channel.id, + 'priority', + idx, + event.target.value + ); + }}> + + } + content='渠道选择优先级,越高越优先' basic /> @@ -341,16 +461,16 @@ const ChannelsTable = () => { > 测试 - + {/* {*/} + {/* updateChannelBalance(channel.id, channel.name, idx);*/} + {/* }}*/} + {/*>*/} + {/* 更新余额*/} + {/**/} @@ -398,14 +518,29 @@ const ChannelsTable = () => { - + - + + + 删除禁用渠道 + + } + on='click' + flowing + hoverable + > + + { 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 ( diff --git a/web/src/components/GitHubOAuth.js b/web/src/components/GitHubOAuth.js index 147d4d30..c43ed2a1 100644 --- a/web/src/components/GitHubOAuth.js +++ b/web/src/components/GitHubOAuth.js @@ -13,8 +13,8 @@ const GitHubOAuth = () => { let navigate = useNavigate(); - const sendCode = async (code, count) => { - const res = await API.get(`/api/oauth/github?code=${code}`); + const sendCode = async (code, state, count) => { + const res = await API.get(`/api/oauth/github?code=${code}&state=${state}`); const { success, message, data } = res.data; if (success) { if (message === 'bind') { @@ -36,13 +36,14 @@ const GitHubOAuth = () => { count++; setPrompt(`出现错误,第 ${count} 次重试中...`); await new Promise((resolve) => setTimeout(resolve, count * 2000)); - await sendCode(code, count); + await sendCode(code, state, count); } }; useEffect(() => { let code = searchParams.get('code'); - sendCode(code, 0).then(); + let state = searchParams.get('state'); + sendCode(code, state, 0).then(); }, []); return ( diff --git a/web/src/components/Header.js b/web/src/components/Header.js index ce5108f9..21ebcab6 100644 --- a/web/src/components/Header.js +++ b/web/src/components/Header.js @@ -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' } } > diff --git a/web/src/components/LoginForm.js b/web/src/components/LoginForm.js index 52e3c840..a3913220 100644 --- a/web/src/components/LoginForm.js +++ b/web/src/components/LoginForm.js @@ -1,36 +1,26 @@ import React, { useContext, useEffect, useState } from 'react'; -import { - Button, - Divider, - Form, - Grid, - Header, - Image, - Message, - Modal, - Segment, -} from 'semantic-ui-react'; +import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { UserContext } from '../context/User'; -import { API, getLogo, showError, showSuccess } from '../helpers'; +import { API, getLogo, showError, showSuccess, showWarning } from '../helpers'; +import { onGitHubOAuthClicked } from './utils'; const LoginForm = () => { const [inputs, setInputs] = useState({ username: '', password: '', - wechat_verification_code: '', + wechat_verification_code: '' }); const [searchParams, setSearchParams] = useSearchParams(); const [submitted, setSubmitted] = useState(false); const { username, password } = inputs; const [userState, userDispatch] = useContext(UserContext); let navigate = useNavigate(); - const [status, setStatus] = useState({}); const logo = getLogo(); useEffect(() => { - if (searchParams.get("expired")) { + if (searchParams.get('expired')) { showError('未登录或登录已过期,请重新登录!'); } let status = localStorage.getItem('status'); @@ -42,12 +32,6 @@ const LoginForm = () => { const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); - const onGitHubOAuthClicked = () => { - window.open( - `https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email` - ); - }; - const onWeChatLoginClicked = () => { setShowWeChatLoginModal(true); }; @@ -76,16 +60,22 @@ const LoginForm = () => { async function handleSubmit(e) { setSubmitted(true); if (username && password) { - const res = await API.post('/api/user/login', { + const res = await API.post(`/api/user/login`, { username, - password, + password }); const { success, message, data } = res.data; if (success) { userDispatch({ type: 'login', payload: data }); localStorage.setItem('user', JSON.stringify(data)); - navigate('/'); - showSuccess('登录成功!'); + if (username === 'root' && password === '123456') { + navigate('/user/edit'); + showSuccess('登录成功!'); + showWarning('请立刻修改默认密码!'); + } else { + navigate('/token'); + showSuccess('登录成功!'); + } } else { showError(message); } @@ -93,44 +83,44 @@ const LoginForm = () => { } return ( - + -
+
用户登录
-
+ - 忘记密码? - + 点击重置 ; 没有账户? - + 点击注册 @@ -140,9 +130,9 @@ const LoginForm = () => { {status.github_oauth ? (
+ + + { + sortLog('created_time'); + }} + width={3} + > + 时间 + + { + isAdminUser && { + sortLog('channel'); + }} + width={1} + > + 渠道 + + } + { + isAdminUser && { + sortLog('username'); + }} + width={1} + > + 用户 + + } + { + sortLog('token_name'); + }} + width={1} + > + 令牌 + + { + sortLog('type'); + }} + width={1} + > + 类型 + + { + sortLog('model_name'); + }} + width={2} + > + 模型 + + { + sortLog('prompt_tokens'); + }} + width={1} + > + 提示 + + { + sortLog('completion_tokens'); + }} + width={1} + > + 补全 + + { + sortLog('quota'); + }} + width={1} + > + 额度 + + { + sortLog('content'); + }} + width={isAdminUser ? 4 : 6} + > + 详情 + + + + + + {logs + .slice( + (activePage - 1) * ITEMS_PER_PAGE, + activePage * ITEMS_PER_PAGE + ) + .map((log, idx) => { + if (log.deleted) return <>; + return ( + + {renderTimestamp(log.created_at)} + { + isAdminUser && ( + {log.channel ? : ''} + ) + } + { + isAdminUser && ( + {log.username ? : ''} + ) + } + {log.token_name ? : ''} + {renderType(log.type)} + {log.model_name ? : ''} + {log.prompt_tokens ? log.prompt_tokens : ''} + {log.completion_tokens ? log.completion_tokens : ''} + {log.quota ? renderQuota(log.quota, 6) : ''} + {log.content} + + ); + })} + + + + + +
+ + + ); +}; + +export default LogsTable; diff --git a/web/src/components/OperationSetting.js b/web/src/components/OperationSetting.js new file mode 100644 index 00000000..bf8b5ffd --- /dev/null +++ b/web/src/components/OperationSetting.js @@ -0,0 +1,360 @@ +import React, { useEffect, useState } from 'react'; +import { Divider, Form, Grid, Header } from 'semantic-ui-react'; +import { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers'; + +const OperationSetting = () => { + let now = new Date(); + 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: '', + ApproximateTokenEnabled: '', + RetryTimes: 0 + }); + const [originInputs, setOriginInputs] = useState({}); + let [loading, setLoading] = useState(false); + let [historyTimestamp, setHistoryTimestamp] = useState(timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)); // a month ago + + 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['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); + } + if (originInputs['RetryTimes'] !== inputs.RetryTimes) { + await updateOption('RetryTimes', inputs.RetryTimes); + } + break; + } + }; + + const deleteHistoryLogs = async () => { + console.log(inputs); + const res = await API.delete(`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`); + const { success, message, data } = res.data; + if (success) { + showSuccess(`${data} 条日志已清理!`); + return; + } + showError('日志清理失败:' + message); + }; + + return ( + + +
+
+ 通用设置 +
+ + + + + + + + + + + + { + submitConfig('general').then(); + }}>保存通用设置 + +
+ 日志设置 +
+ + + + + { + setHistoryTimestamp(value); + }} /> + + { + deleteHistoryLogs().then(); + }}>清理历史日志 + +
+ 监控设置 +
+ + + + + + + + { + submitConfig('monitor').then(); + }}>保存监控设置 + +
+ 额度设置 +
+ + + + + + + { + submitConfig('quota').then(); + }}>保存额度设置 + +
+ 倍率设置 +
+ + + + + + + { + submitConfig('ratio').then(); + }}>保存倍率设置 + +
+
+ ); +}; + +export default OperationSetting; diff --git a/web/src/components/OtherSetting.js b/web/src/components/OtherSetting.js index 1ea5bf8f..526a7d86 100644 --- a/web/src/components/OtherSetting.js +++ b/web/src/components/OtherSetting.js @@ -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); } @@ -112,7 +112,7 @@ const OtherSetting = () => { { style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} /> - submitOption('HomePageContent')}>保存首页内容 + submitOption('HomePageContent')}>保存首页内容 { /> 保存关于 + 移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。 { @@ -12,6 +12,11 @@ const PasswordResetConfirm = () => { const [loading, setLoading] = useState(false); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + + const [newPassword, setNewPassword] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { let token = searchParams.get('token'); @@ -22,7 +27,21 @@ const PasswordResetConfirm = () => { }); }, []); + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + async function handleSubmit(e) { + setDisableButton(true); if (!email) return; setLoading(true); const res = await API.post(`/api/user/reset`, { @@ -32,14 +51,15 @@ const PasswordResetConfirm = () => { const { success, message } = res.data; if (success) { let password = res.data.data; + setNewPassword(password); await copy(password); - showSuccess(`密码已重置并已复制到剪贴板:${password}`); + showNotice(`新密码已复制到剪贴板:${password}`); } else { showError(message); } setLoading(false); } - + return ( @@ -57,20 +77,37 @@ const PasswordResetConfirm = () => { value={email} readOnly /> + {newPassword && ( + { + e.target.select(); + navigator.clipboard.writeText(newPassword); + showNotice(`密码已复制到剪贴板:${newPassword}`); + }} + /> + )} - ); + ); }; export default PasswordResetConfirm; diff --git a/web/src/components/PasswordResetForm.js b/web/src/components/PasswordResetForm.js index 8de95d6c..f3610d3a 100644 --- a/web/src/components/PasswordResetForm.js +++ b/web/src/components/PasswordResetForm.js @@ -5,7 +5,7 @@ import Turnstile from 'react-turnstile'; const PasswordResetForm = () => { const [inputs, setInputs] = useState({ - email: '', + email: '' }); const { email } = inputs; @@ -13,24 +13,29 @@ const PasswordResetForm = () => { const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); } - }, []); + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); function handleChange(e) { const { name, value } = e.target; - setInputs((inputs) => ({ ...inputs, [name]: value })); + setInputs(inputs => ({ ...inputs, [name]: value })); } async function handleSubmit(e) { + setDisableButton(true); if (!email) return; if (turnstileEnabled && turnstileToken === '') { showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); @@ -78,13 +83,14 @@ const PasswordResetForm = () => { <> )} diff --git a/web/src/components/PersonalSetting.js b/web/src/components/PersonalSetting.js index d3216811..6baf1f35 100644 --- a/web/src/components/PersonalSetting.js +++ b/web/src/components/PersonalSetting.js @@ -1,22 +1,33 @@ -import React, { useEffect, useState } from 'react'; +import React, { useContext, 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 { Link, useNavigate } from 'react-router-dom'; +import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; import Turnstile from 'react-turnstile'; +import { UserContext } from '../context/User'; +import { onGitHubOAuthClicked } from './utils'; const PersonalSetting = () => { + const [userState, userDispatch] = useContext(UserContext); + let navigate = useNavigate(); + const [inputs, setInputs] = useState({ wechat_verification_code: '', email_verification_code: '', email: '', + self_account_deletion_confirmation: '' }); const [status, setStatus] = useState({}); const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); const [showEmailBindModal, setShowEmailBindModal] = useState(false); + const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); const [loading, setLoading] = useState(false); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + const [affLink, setAffLink] = useState(""); + const [systemToken, setSystemToken] = useState(""); useEffect(() => { let status = localStorage.getItem('status'); @@ -30,6 +41,19 @@ const PersonalSetting = () => { } }, []); + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); // Clean up on unmount + }, [disableButton, countdown]); + const handleInputChange = (e, { name, value }) => { setInputs((inputs) => ({ ...inputs, [name]: value })); }; @@ -38,8 +62,56 @@ const PersonalSetting = () => { const res = await API.get('/api/user/token'); const { success, message, data } = res.data; if (success) { + setSystemToken(data); + setAffLink(""); await copy(data); - showSuccess(`令牌已重置并已复制到剪贴板:${data}`); + showSuccess(`令牌已重置并已复制到剪贴板`); + } else { + showError(message); + } + }; + + 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}`; + setAffLink(link); + setSystemToken(""); + await copy(link); + showSuccess(`邀请链接已复制到剪切板`); + } else { + showError(message); + } + }; + + const handleAffLinkClick = async (e) => { + e.target.select(); + await copy(e.target.value); + showSuccess(`邀请链接已复制到剪切板`); + }; + + const handleSystemTokenClick = async (e) => { + e.target.select(); + await copy(e.target.value); + showSuccess(`系统令牌已复制到剪切板`); + }; + + const deleteAccount = async () => { + if (inputs.self_account_deletion_confirmation !== userState.user.username) { + showError('请输入你的账户名以确认删除!'); + return; + } + + const res = await API.delete('/api/user/self'); + const { success, message } = res.data; + + if (success) { + showSuccess('账户已删除!'); + await API.get('/api/user/logout'); + userDispatch({ type: 'logout' }); + localStorage.removeItem('user'); + navigate('/login'); } else { showError(message); } @@ -59,13 +131,8 @@ const PersonalSetting = () => { } }; - const openGitHubOAuth = () => { - window.open( - `https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email` - ); - }; - const sendVerificationCode = async () => { + setDisableButton(true); if (inputs.email === '') return; if (turnstileEnabled && turnstileToken === '') { showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); @@ -110,6 +177,29 @@ const PersonalSetting = () => { 更新个人信息 + + + + {systemToken && ( + + )} + {affLink && ( + + )}
账号绑定
{ @@ -154,7 +244,7 @@ const PersonalSetting = () => { { status.github_oauth && ( - + ) } } /> @@ -204,6 +294,7 @@ const PersonalSetting = () => { ) : ( <> )} +
+
+ +
+ + + + + setShowAccountDeleteModal(false)} + onOpen={() => setShowAccountDeleteModal(true)} + open={showAccountDeleteModal} + size={'tiny'} + style={{ maxWidth: '450px' }} + > + 危险操作 + + 您正在删除自己的帐户,将清空所有数据且不可恢复 + +
+ + {turnstileEnabled ? ( + { + setTurnstileToken(token); + }} + /> + ) : ( + <> + )} +
+ +
+ +
diff --git a/web/src/components/RedemptionsTable.js b/web/src/components/RedemptionsTable.js index 810b1054..dfd59685 100644 --- a/web/src/components/RedemptionsTable.js +++ b/web/src/components/RedemptionsTable.js @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { Button, Form, Label, Message, Pagination, Table } from 'semantic-ui-react'; +import { Button, Form, Label, Popup, Pagination, Table } from 'semantic-ui-react'; 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 ( @@ -129,7 +130,13 @@ const RedemptionsTable = () => { setLoading(true); let sortedRedemptions = [...redemptions]; sortedRedemptions.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); + if (!isNaN(a[key])) { + // If the value is numeric, subtract to sort + return a[key] - b[key]; + } else { + // If the value is not numeric, sort as strings + return ('' + a[key]).localeCompare(b[key]); + } }); if (sortedRedemptions[0].id === redemptions[0].id) { sortedRedemptions.reverse(); @@ -152,7 +159,7 @@ const RedemptionsTable = () => { /> - +
{ {redemption.id} {redemption.name ? redemption.name : '无'} {renderStatus(redemption.status)} - {redemption.quota} + {renderQuota(redemption.quota)} {renderTimestamp(redemption.created_time)} {redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} @@ -239,15 +246,25 @@ const RedemptionsTable = () => { > 复制 - + } + on='click' + flowing + hoverable > - 删除 - + + + + + + } {
- 运营设置 -
- - - - - - - - - - 保存运营设置 - -
- 监控设置 + 配置邮箱域名白名单 + 用以防止恶意用户利用临时邮箱批量注册
- - - + + + { + submitNewRestrictedDomain(); + }}>填入 + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + submitNewRestrictedDomain(); + } + }} + autoComplete='new-password' + placeholder='输入新的允许的邮箱域名' + value={restrictedDomainInput} + onChange={(e, { value }) => { + setRestrictedDomainInput(value); + }} + /> + + 保存邮箱域名白名单设置
配置 SMTP @@ -400,7 +408,7 @@ const SystemSetting = () => { onChange={handleInputChange} type='password' autoComplete='new-password' - value={inputs.SMTPToken} + checked={inputs.RegisterEnabled === 'true'} placeholder='敏感信息不会发送到前端显示' /> diff --git a/web/src/components/TokensTable.js b/web/src/components/TokensTable.js index fa77a32d..db4745e4 100644 --- a/web/src/components/TokensTable.js +++ b/web/src/components/TokensTable.js @@ -1,9 +1,21 @@ import React, { useEffect, useState } from 'react'; -import { Button, Form, Label, Modal, Pagination, Popup, Table } from 'semantic-ui-react'; +import { Button, Dropdown, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; 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'; + +const COPY_OPTIONS = [ + { key: 'next', text: 'ChatGPT Next Web', value: 'next' }, + { key: 'ama', text: 'AMA 问天', value: 'ama' }, + { key: 'opencat', text: 'OpenCat', value: 'opencat' }, +]; + +const OPEN_LINK_OPTIONS = [ + { key: 'ama', text: 'AMA 问天', value: 'ama' }, + { key: 'opencat', text: 'OpenCat', value: 'opencat' }, +]; function renderTimestamp(timestamp) { return ( @@ -44,8 +56,8 @@ const TokensTable = () => { if (startIdx === 0) { setTokens(data); } else { - let newTokens = tokens; - newTokens.push(...data); + let newTokens = [...tokens]; + newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); setTokens(newTokens); } } else { @@ -66,7 +78,85 @@ const TokensTable = () => { const refresh = async () => { setLoading(true); - await loadTokens(0); + await loadTokens(activePage - 1); + }; + + const onCopy = async (type, key) => { + let status = localStorage.getItem('status'); + let serverAddress = ''; + if (status) { + status = JSON.parse(status); + serverAddress = status.server_address; + } + if (serverAddress === '') { + serverAddress = window.location.origin; + } + let encodedServerAddress = encodeURIComponent(serverAddress); + const nextLink = localStorage.getItem('chat_link'); + let nextUrl; + + if (nextLink) { + nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; + } else { + nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; + } + + let url; + switch (type) { + case 'ama': + url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`; + break; + case 'opencat': + url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`; + break; + case 'next': + url = nextUrl; + break; + default: + url = `sk-${key}`; + } + if (await copy(url)) { + showSuccess('已复制到剪贴板!'); + } else { + showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。'); + setSearchKeyword(url); + } + }; + + const onOpenLink = async (type, key) => { + let status = localStorage.getItem('status'); + let serverAddress = ''; + if (status) { + status = JSON.parse(status); + serverAddress = status.server_address; + } + if (serverAddress === '') { + serverAddress = window.location.origin; + } + let encodedServerAddress = encodeURIComponent(serverAddress); + const chatLink = localStorage.getItem('chat_link'); + let defaultUrl; + + if (chatLink) { + defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; + } else { + defaultUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; + } + let url; + switch (type) { + case 'ama': + url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`; + break; + + case 'opencat': + url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`; + break; + + default: + url = defaultUrl; + } + + window.open(url, '_blank'); } useEffect(() => { @@ -138,7 +228,13 @@ const TokensTable = () => { setLoading(true); let sortedTokens = [...tokens]; sortedTokens.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); + if (!isNaN(a[key])) { + // If the value is numeric, subtract to sort + return a[key] - b[key]; + } else { + // If the value is not numeric, sort as strings + return ('' + a[key]).localeCompare(b[key]); + } }); if (sortedTokens[0].id === tokens[0].id) { sortedTokens.reverse(); @@ -154,24 +250,16 @@ const TokensTable = () => { icon='search' fluid iconPosition='left' - placeholder='搜索令牌的 ID 和名称 ...' + placeholder='搜索令牌的名称 ...' value={searchKeyword} loading={searching} onChange={handleKeywordChange} /> -
+
- { - sortToken('id'); - }} - > - ID - { @@ -188,13 +276,21 @@ const TokensTable = () => { > 状态 + { + sortToken('used_quota'); + }} + > + 已用额度 + { sortToken('remain_quota'); }} > - 额度 + 剩余额度 { if (token.deleted) return <>; return ( - {token.id} {token.name ? token.name : '无'} {renderStatus(token.status)} - {token.unlimited_quota ? '无限制' : token.remain_quota} + {renderQuota(token.used_quota)} + {token.unlimited_quota ? '无限制' : renderQuota(token.remain_quota, 2)} {renderTimestamp(token.created_time)} {token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}
- + + + ({ + ...option, + onClick: async () => { + await onCopy(option.value, token.key); + } + }))} + trigger={<>} + /> + + {' '} + + + ({ + ...option, + onClick: async () => { + await onOpenLink(option.value, token.key); + } + }))} + trigger={<>} + /> + + {' '} @@ -295,7 +422,7 @@ const TokensTable = () => { - + diff --git a/web/src/components/UsersTable.js b/web/src/components/UsersTable.js index 1fdd8923..ad4e9b49 100644 --- a/web/src/components/UsersTable.js +++ b/web/src/components/UsersTable.js @@ -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) { @@ -133,7 +133,13 @@ const UsersTable = () => { setLoading(true); let sortedUsers = [...users]; sortedUsers.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); + if (!isNaN(a[key])) { + // If the value is numeric, subtract to sort + return a[key] - b[key]; + } else { + // If the value is not numeric, sort as strings + return ('' + a[key]).localeCompare(b[key]); + } }); if (sortedUsers[0].id === users[0].id) { sortedUsers.reverse(); @@ -156,7 +162,7 @@ const UsersTable = () => { /> -
+
{ { - sortUser('email'); + sortUser('group'); }} > - 邮箱地址 + 分组 { sortUser('quota'); }} > - 剩余额度 + 统计信息 { {renderText(user.username, 10)}} + trigger={{renderText(user.username, 15)}} hoverable /> - {user.email ? renderText(user.email, 30) : '无'} - {user.quota} + {renderGroup(user.group)} + {/**/} + {/* {user.email ? {renderText(user.email, 24)}} /> : '无'}*/} + {/**/} + + {renderQuota(user.quota)}} /> + {renderQuota(user.used_quota)}} /> + {renderNumber(user.request_count)}} /> + {renderRole(user.role)} {renderStatus(user.status)} @@ -293,7 +306,6 @@ const UsersTable = () => { size={'small'} as={Link} to={'/user/edit/' + user.id} - disabled={user.role === 100} > 编辑 diff --git a/web/src/components/utils.js b/web/src/components/utils.js new file mode 100644 index 00000000..5363ba5e --- /dev/null +++ b/web/src/components/utils.js @@ -0,0 +1,20 @@ +import { API, showError } from '../helpers'; + +export async function getOAuthState() { + const res = await API.get('/api/oauth/state'); + const { success, message, data } = res.data; + if (success) { + return data; + } else { + showError(message); + return ''; + } +} + +export async function onGitHubOAuthClicked(github_client_id) { + const state = await getOAuthState(); + if (!state) return; + window.open( + `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email` + ); +} \ No newline at end of file diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index f84a4deb..76407745 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -1,12 +1,25 @@ export const CHANNEL_OPTIONS = [ { key: 1, text: 'OpenAI', value: 1, color: 'green' }, - { key: 8, text: '自定义', value: 8, color: 'pink' }, - { key: 3, text: 'Azure', value: 3, color: 'olive' }, - { key: 2, text: 'API2D', value: 2, color: 'blue' }, - { key: 4, text: 'CloseAI', value: 4, color: 'teal' }, - { key: 5, text: 'OpenAI-SB', value: 5, color: 'brown' }, - { 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: 14, text: 'Anthropic Claude', value: 14, color: 'black' }, + { key: 3, text: 'Azure OpenAI', value: 3, color: 'olive' }, + { key: 11, text: 'Google PaLM2', value: 11, color: 'orange' }, + { key: 15, text: '百度文心千帆', value: 15, color: 'blue' }, + { key: 17, text: '阿里通义千问', value: 17, color: 'orange' }, + { key: 18, text: '讯飞星火认知', value: 18, color: 'blue' }, + { key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' }, + { key: 19, text: '360 智脑', value: 19, color: 'blue' }, + { key: 23, text: '腾讯混元', value: 23, color: 'teal' }, + { key: 8, text: '自定义渠道', value: 8, color: 'pink' }, + { key: 22, text: '知识库:FastGPT', value: 22, color: 'blue' }, + { key: 21, text: '知识库:AI Proxy', value: 21, color: 'purple' }, + { key: 20, text: '代理:OpenRouter', value: 20, color: 'black' }, + { key: 2, text: '代理:API2D', value: 2, color: 'blue' }, + { key: 5, text: '代理:OpenAI-SB', value: 5, color: 'brown' }, + { key: 7, text: '代理:OhMyGPT', value: 7, color: 'purple' }, + { key: 10, text: '代理:AI Proxy', value: 10, color: 'purple' }, + { key: 4, text: '代理:CloseAI', value: 4, color: 'teal' }, + { key: 6, text: '代理:OpenAI Max', value: 6, color: 'violet' }, + { key: 9, text: '代理:AI.LS', value: 9, color: 'yellow' }, + { key: 12, text: '代理:API2GPT', value: 12, color: 'blue' }, + { key: 13, text: '代理:AIGC2D', value: 13, color: 'purple' } +]; \ No newline at end of file diff --git a/web/src/constants/toast.constants.js b/web/src/constants/toast.constants.js index 8b212350..50684722 100644 --- a/web/src/constants/toast.constants.js +++ b/web/src/constants/toast.constants.js @@ -1,5 +1,5 @@ export const toastConstants = { - SUCCESS_TIMEOUT: 500, + SUCCESS_TIMEOUT: 1500, INFO_TIMEOUT: 3000, ERROR_TIMEOUT: 5000, WARNING_TIMEOUT: 10000, diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 20bfedd5..a9c81cc1 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -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 ; + } + let groups = group.split(','); + groups.sort(); + return <> + {groups.map((group) => { + if (group === 'vip' || group === 'pro') { + return ; + } else if (group === 'svip' || group === 'premium') { + return ; + } + return ; + })} + ; +} + +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 ''; } \ No newline at end of file diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 36f0d4b0..28ae4992 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -1,6 +1,11 @@ import { toast } from 'react-toastify'; import { toastConstants } from '../constants'; +import React from 'react'; +const HTMLToastContent = ({ htmlContent }) => { + return
; +}; +export default HTMLToastContent; export function isAdmin() { let user = localStorage.getItem('user'); if (!user) return false; @@ -107,8 +112,12 @@ export function showInfo(message) { toast.info(message, showInfoOptions); } -export function showNotice(message) { - toast.info(message, showNoticeOptions); +export function showNotice(message, isHTML = false) { + if (isHTML) { + toast(, showNoticeOptions); + } else { + toast.info(message, showNoticeOptions); + } } export function openPage(url) { @@ -177,4 +186,14 @@ export const verifyJSON = (str) => { return false; } return true; -}; \ No newline at end of file +}; + +export function shouldShowPrompt(id) { + let prompt = localStorage.getItem(`prompt-${id}`); + return !prompt; + +} + +export function setPromptShown(id) { + localStorage.setItem(`prompt-${id}`, 'true'); +} \ No newline at end of file diff --git a/web/src/pages/About/index.js b/web/src/pages/About/index.js index 6a05f526..ec13f151 100644 --- a/web/src/pages/About/index.js +++ b/web/src/pages/About/index.js @@ -46,9 +46,7 @@ const About = () => { about.startsWith('https://') ?