mirror of
				https://github.com/songquanpeng/one-api.git
				synced 2025-10-25 19:03:43 +08:00 
			
		
		
		
	Compare commits
	
		
			131 Commits
		
	
	
		
			v0.5.3-alp
			...
			v0.5.8-alp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 58bb3ab6f6 | ||
|  | d306cb5229 | ||
|  | 6c5307d0c4 | ||
|  | 7c4505bdfc | ||
|  | 9d43ec57d8 | ||
|  | e5311892d1 | ||
|  | bc7c9105f4 | ||
|  | 3fe76c8af7 | ||
|  | c70c614018 | ||
|  | 0d87de697c | ||
|  | aec343dc38 | ||
|  | 89d458b9cf | ||
|  | 63fafba112 | ||
|  | a398f35968 | ||
|  | 57aa637c77 | ||
|  | 3b483639a4 | ||
|  | 22980b4c44 | ||
|  | 64cdb7eafb | ||
|  | 824444244b | ||
|  | fbe9985f57 | ||
|  | a27a5bcc06 | ||
|  | e28d4b1741 | ||
|  | f073592d39 | ||
|  | fa41ca9805 | ||
|  | e338de45b6 | ||
|  | 114587b46f | ||
|  | b4b4acc288 | ||
|  | d663de3e3a | ||
|  | a85ecace2e | ||
|  | fbdea91ea1 | ||
|  | 8d34b7a77e | ||
|  | cbd62011b8 | ||
|  | 4701897e2e | ||
|  | 0f6c132a80 | ||
|  | 3cac45dc85 | ||
|  | 47c08c72ce | ||
|  | 53b2cace0b | ||
|  | f0fc991b44 | ||
|  | 594f06e7b0 | ||
|  | 197d1d7a9d | ||
|  | f9b748c2ca | ||
|  | fd98463611 | ||
|  | f5a1cd3463 | ||
|  | 8651451e53 | ||
|  | 1c5bb97a42 | ||
|  | de868e4e4e | ||
|  | 1d258cc898 | ||
|  | 37e09d764c | ||
|  | 159b9e3369 | ||
|  | 92001986db | ||
|  | a5647b1ea7 | ||
|  | 215e54fc96 | ||
|  | ecf8a6d875 | ||
|  | 24df3e5f62 | ||
|  | 12ef9679a7 | ||
|  | 328aa68255 | ||
|  | 4335f005a6 | ||
|  | fe26a1448d | ||
|  | 42451d9d02 | ||
|  | 25c4c111ab | ||
|  | 0d50ad4b2b | ||
|  | 959bcdef88 | ||
|  | 39ae8075e4 | ||
|  | b57a0eca16 | ||
|  | 1b4cc78890 | ||
|  | 420c375140 | ||
|  | 01863d3e44 | ||
|  | d0a0e871e1 | ||
|  | bd6fe1e93c | ||
|  | c55bb67818 | ||
|  | 0f949c3782 | ||
|  | a721a5b6f9 | ||
|  | 276163affd | ||
|  | 621eb91b46 | ||
|  | 7e575abb95 | ||
|  | 9db93316c4 | ||
|  | c3dc315e75 | ||
|  | 04acdb1ccb | ||
|  | f0d5e102a3 | ||
|  | abbf2fded0 | ||
|  | ef2c5abb5b | ||
|  | 56b5007379 | ||
|  | d09d317459 | ||
|  | 1c4409ae80 | ||
|  | 5ee24e8acf | ||
|  | 4f2f911e4d | ||
|  | fdb2cccf65 | ||
|  | a3e267df7e | ||
|  | ac7c0f3a76 | ||
|  | efeb9a16ce | ||
|  | 05e4f2b439 | ||
|  | 7e058bfb9b | ||
|  | dfaa0183b7 | ||
|  | 1b56becfaa | ||
|  | 23b1c63538 | ||
|  | 49d1a63402 | ||
|  | 2a7b82650c | ||
|  | 8ea7b9aae2 | ||
|  | 5136b12612 | ||
|  | 80a49e01a3 | ||
|  | 8fb082ba3b | ||
|  | 86c2627c24 | ||
|  | 90b4cac7f3 | ||
|  | e4bacc45d6 | ||
|  | da1d81998f | ||
|  | cac61b9f66 | ||
|  | 3da12e99d9 | ||
|  | 4ef5e2020c | ||
|  | af20063a8d | ||
|  | ca512f6a38 | ||
|  | 0e9ff8825e | ||
|  | e0b4f96b5b | ||
|  | eae9b6e607 | ||
|  | 7bddc73b96 | ||
|  | 2a527ee436 | ||
|  | e42119b73d | ||
|  | 821c559e89 | ||
|  | 7e2bca7e9c | ||
|  | 1e16ef3e0d | ||
|  | 476a46ad7e | ||
|  | c58f710227 | ||
|  | 150d068e9f | ||
|  | be780462f1 | ||
|  | f2159e1033 | ||
|  | 466005de07 | ||
|  | 2b088a1678 | ||
|  | 3a18cebe34 | ||
|  | cc36bf9c13 | ||
|  | 3b36608bbd | ||
|  | 29fa94e7d2 | ||
|  | 9c436921d1 | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -4,4 +4,6 @@ upload | ||||
| *.exe | ||||
| *.db | ||||
| build | ||||
| *.db-journal | ||||
| *.db-journal | ||||
| logs | ||||
| data | ||||
| @@ -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 | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <p align="right"> | ||||
|     <a href="./README.md">中文</a> | <strong>English</strong> | ||||
|     <a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.ja.md">日本語</a> | ||||
| </p> | ||||
|  | ||||
| <p align="center"> | ||||
| @@ -189,8 +189,10 @@ If you encounter a blank page after deployment, refer to [#97](https://github.co | ||||
|  | ||||
| > Zeabur's servers are located overseas, automatically solving network issues, and the free quota is sufficient for personal usage. | ||||
|  | ||||
| [](https://zeabur.com/templates/7Q0KO3) | ||||
|  | ||||
| 1. First, fork the code. | ||||
| 2. Go to [Zeabur](https://zeabur.com/), log in, and enter the console. | ||||
| 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. | ||||
| @@ -283,7 +285,7 @@ If the channel ID is not provided, load balancing will be used to distribute the | ||||
|     + Double-check that your interface address and API Key are correct. | ||||
|  | ||||
| ## Related Projects | ||||
| [FastGPT](https://github.com/c121914yu/FastGPT): Build an AI knowledge base in three minutes | ||||
| [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. | ||||
|   | ||||
							
								
								
									
										300
									
								
								README.ja.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										300
									
								
								README.ja.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,300 @@ | ||||
| <p align="right"> | ||||
|     <a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>日本語</strong> | ||||
| </p> | ||||
|  | ||||
| <p align="center"> | ||||
|   <a href="https://github.com/songquanpeng/one-api"><img src="https://raw.githubusercontent.com/songquanpeng/one-api/main/web/public/logo.png" width="150" height="150" alt="one-api logo"></a> | ||||
| </p> | ||||
|  | ||||
| <div align="center"> | ||||
|  | ||||
| # One API | ||||
|  | ||||
| _✨ 標準的な OpenAI API フォーマットを通じてすべての LLM にアクセスでき、導入と利用が容易です ✨_ | ||||
|  | ||||
| </div> | ||||
|  | ||||
| <p align="center"> | ||||
|   <a href="https://raw.githubusercontent.com/songquanpeng/one-api/main/LICENSE"> | ||||
|     <img src="https://img.shields.io/github/license/songquanpeng/one-api?color=brightgreen" alt="license"> | ||||
|   </a> | ||||
|   <a href="https://github.com/songquanpeng/one-api/releases/latest"> | ||||
|     <img src="https://img.shields.io/github/v/release/songquanpeng/one-api?color=brightgreen&include_prereleases" alt="release"> | ||||
|   </a> | ||||
|   <a href="https://hub.docker.com/repository/docker/justsong/one-api"> | ||||
|     <img src="https://img.shields.io/docker/pulls/justsong/one-api?color=brightgreen" alt="docker pull"> | ||||
|   </a> | ||||
|   <a href="https://github.com/songquanpeng/one-api/releases/latest"> | ||||
|     <img src="https://img.shields.io/github/downloads/songquanpeng/one-api/total?color=brightgreen&include_prereleases" alt="release"> | ||||
|   </a> | ||||
|   <a href="https://goreportcard.com/report/github.com/songquanpeng/one-api"> | ||||
|     <img src="https://goreportcard.com/badge/github.com/songquanpeng/one-api" alt="GoReportCard"> | ||||
|   </a> | ||||
| </p> | ||||
|  | ||||
| <p align="center"> | ||||
|   <a href="#deployment">デプロイチュートリアル</a> | ||||
|   · | ||||
|   <a href="#usage">使用方法</a> | ||||
|   · | ||||
|   <a href="https://github.com/songquanpeng/one-api/issues">フィードバック</a> | ||||
|   · | ||||
|   <a href="#screenshots">スクリーンショット</a> | ||||
|   · | ||||
|   <a href="https://openai.justsong.cn/">ライブデモ</a> | ||||
|   · | ||||
|   <a href="#faq">FAQ</a> | ||||
|   · | ||||
|   <a href="#related-projects">関連プロジェクト</a> | ||||
|   · | ||||
|   <a href="https://iamazing.cn/page/reward">寄付</a> | ||||
| </p> | ||||
|  | ||||
| > **警告**: この 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) を参照してください。 | ||||
|  | ||||
| ### サードパーティプラットフォームへのデプロイ | ||||
| <details> | ||||
| <summary><strong>Sealos へのデプロイ</strong></summary> | ||||
| <div> | ||||
|  | ||||
| > Sealos は、高い同時実行性、ダイナミックなスケーリング、数百万人のユーザーに対する安定した運用をサポートしています。 | ||||
|  | ||||
| > 下のボタンをクリックすると、ワンクリックで展開できます。👇 | ||||
|  | ||||
| [](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api) | ||||
|  | ||||
|  | ||||
| </div> | ||||
| </details> | ||||
|  | ||||
| <details> | ||||
| <summary><strong>Zeabur へのデプロイ</strong></summary> | ||||
| <div> | ||||
|  | ||||
| > Zeabur のサーバーは海外にあるため、ネットワークの問題は自動的に解決されます。 | ||||
|  | ||||
| [](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` に `<username>:<password>@tcp(<addr>:<port>)/one-api` を追加します。変更を保存する。SQL_DSN` が設定されていないと、データが永続化されず、再デプロイ後にデータが失われるので注意すること。 | ||||
| 7. 再デプロイを選択します。 | ||||
| 8. Domains タブで、"my-one-api" のような適切なドメイン名の接頭辞を選択する。最終的なドメイン名は "my-one-api.zeabur.app" となります。独自のドメイン名を CNAME することもできます。 | ||||
| 9. デプロイが完了するのを待ち、生成されたドメイン名をクリックして One API にアクセスします。 | ||||
|  | ||||
| </div> | ||||
| </details> | ||||
|  | ||||
| ## コンフィグ | ||||
| システムは箱から出してすぐに使えます。 | ||||
|  | ||||
| 環境変数やコマンドラインパラメータを設定することで、システムを構成することができます。 | ||||
|  | ||||
| システム起動後、`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 <port_number>`: サーバがリッスンするポート番号を指定。デフォルトは `3000` です。 | ||||
|     + 例: `--port 3000` | ||||
| 2. `--log-dir <log_dir>`: ログディレクトリを指定。設定しない場合、ログは保存されません。 | ||||
|     + 例: `--log-dir ./logs` | ||||
| 3. `--version`: システムのバージョン番号を表示して終了する。 | ||||
| 4. `--help`: コマンドの使用法ヘルプとパラメータの説明を表示。 | ||||
|  | ||||
| ## スクリーンショット | ||||
|  | ||||
|  | ||||
|  | ||||
| ## 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 ライセンスによると、このプロジェクトを利用するリスクと責任は利用者が負うべきであり、このオープンソースプロジェクトの開発者は責任を負いません。 | ||||
							
								
								
									
										150
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										150
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,5 +1,5 @@ | ||||
| <p align="right"> | ||||
|    <strong>中文</strong> | <a href="./README.en.md">English</a> | ||||
|    <strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.ja.md">日本語</a> | ||||
| </p> | ||||
|  | ||||
|  | ||||
| @@ -51,11 +51,16 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用  | ||||
|   <a href="https://iamazing.cn/page/reward">赞赏支持</a> | ||||
| </p> | ||||
|  | ||||
| > **Note**:本项目为开源项目,使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。 | ||||
| > **Note** | ||||
| > 本项目为开源项目,使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。 | ||||
| >  | ||||
| > 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。 | ||||
|  | ||||
| > **Note**:使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本。 | ||||
| > **Warning** | ||||
| > 使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本。 | ||||
|  | ||||
| > **Warning**:从 `v0.3` 版本升级到 `v0.4` 版本需要手动迁移数据库,请手动执行[数据库迁移脚本](./bin/migration_v0.3-v0.4.sql)。 | ||||
| > **Warning** | ||||
| > 使用 root 用户初次登录系统后,务必修改默认密码 `123456`! | ||||
|  | ||||
| ## 功能 | ||||
| 1. 支持多种大模型: | ||||
| @@ -66,12 +71,14 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用  | ||||
|    + [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] [CloseAI](https://console.closeai-asia.com/r/2412) | ||||
|    + [x] 自定义渠道:例如各种未收录的第三方代理服务 | ||||
| 3. 支持通过**负载均衡**的方式访问多个渠道。 | ||||
| 4. 支持 **stream 模式**,可以通过流式传输实现打字机效果。 | ||||
| @@ -88,30 +95,39 @@ _✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用  | ||||
| 15. 支持模型映射,重定向用户的请求模型。 | ||||
| 16. 支持失败自动重试。 | ||||
| 17. 支持绘图接口。 | ||||
| 18. 支持丰富的**自定义**设置, | ||||
| 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 嵌入。 | ||||
| 19. 支持通过系统访问令牌访问管理 API。 | ||||
| 20. 支持 Cloudflare Turnstile 用户校验。 | ||||
| 21. 支持用户管理,支持**多种用户登录注册方式**: | ||||
| 20. 支持通过系统访问令牌访问管理 API。 | ||||
| 21. 支持 Cloudflare Turnstile 用户校验。 | ||||
| 22. 支持用户管理,支持**多种用户登录注册方式**: | ||||
|     + 邮箱登录注册(支持注册邮箱白名单)以及通过邮箱进行密码重置。 | ||||
|     + [GitHub 开放授权](https://github.com/settings/applications/new)。 | ||||
|     + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。 | ||||
|  | ||||
| ## 部署 | ||||
| ### 基于 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` | ||||
| ```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` 是宿主机的端口,可以根据需要进行修改。 | ||||
|  | ||||
| 数据和日志将会保存在宿主机的 `/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`,详见下面[环境变量](#环境变量)一节。 | ||||
| 如果你的并发量较大,**务必**设置 `SQL_DSN`,详见下面[环境变量](#环境变量)一节。 | ||||
|  | ||||
| 更新命令:`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR` | ||||
|  | ||||
| `-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。 | ||||
|  | ||||
| 数据将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录,请确保该目录存在且具有写入权限,或者更改为合适的目录。 | ||||
|  | ||||
| Nginx 的参考配置: | ||||
| ``` | ||||
| server{ | ||||
| @@ -144,6 +160,19 @@ 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 | ||||
| @@ -206,6 +235,13 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope | ||||
|  | ||||
| 注意修改端口号、`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)在运行时切换所使用的模型。 | ||||
|  | ||||
| ### 部署到第三方平台 | ||||
| <details> | ||||
| <summary><strong>部署到 Sealos </strong></summary> | ||||
| @@ -213,7 +249,7 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope | ||||
|  | ||||
| > Sealos 的服务器在国外,不需要额外处理网络问题,支持高并发 & 动态伸缩。 | ||||
|  | ||||
| 点击以下按钮一键部署: | ||||
| 点击以下按钮一键部署(部署后访问出现 404 请等待 3~5 分钟): | ||||
|  | ||||
| [](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api) | ||||
|  | ||||
| @@ -224,10 +260,12 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope | ||||
| <summary><strong>部署到 Zeabur</strong></summary> | ||||
| <div> | ||||
|  | ||||
| > Zeabur 的服务器在国外,自动解决了网络的问题,同时免费的额度也足够个人使用。 | ||||
| > Zeabur 的服务器在国外,自动解决了网络的问题,同时免费的额度也足够个人使用 | ||||
|  | ||||
| [](https://zeabur.com/templates/7Q0KO3) | ||||
|  | ||||
| 1. 首先 fork 一份代码。 | ||||
| 2. 进入 [Zeabur](https://zeabur.com/),登录,进入控制台。 | ||||
| 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 的仓库。 | ||||
| @@ -239,6 +277,17 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope | ||||
| </div> | ||||
| </details> | ||||
|  | ||||
| <details> | ||||
| <summary><strong>部署到 Render</strong></summary> | ||||
| <div> | ||||
|  | ||||
| > Render 提供免费额度,绑卡后可以进一步提升额度 | ||||
|  | ||||
| Render 可以直接部署 docker 镜像,不需要 fork 仓库:https://dashboard.render.com | ||||
|  | ||||
| </div> | ||||
| </details> | ||||
|  | ||||
| ## 配置 | ||||
| 系统本身开箱即用。 | ||||
|  | ||||
| @@ -257,13 +306,20 @@ docker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://ope | ||||
|  | ||||
| 注意,具体的 API Base 的格式取决于你所使用的客户端。 | ||||
|  | ||||
| 例如对于 OpenAI 的官方库: | ||||
| ```bash | ||||
| OPENAI_API_KEY="sk-xxxxxx" | ||||
| OPENAI_API_BASE="https://<HOST>:<PORT>/v1"  | ||||
| ``` | ||||
|  | ||||
| ```mermaid | ||||
| graph LR | ||||
|     A(用户) | ||||
|     A --->|请求| B(One API) | ||||
|     A --->|使用 One API 分发的 key 进行请求| B(One API) | ||||
|     B -->|中继请求| C(OpenAI) | ||||
|     B -->|中继请求| D(Azure) | ||||
|     B -->|中继请求| E(其他下游渠道) | ||||
|     B -->|中继请求| E(其他 OpenAI API 格式下游渠道) | ||||
|     B -->|中继并修改请求体和返回体| F(非 OpenAI API 格式下游渠道) | ||||
| ``` | ||||
|  | ||||
| 可以通过在令牌后面添加渠道 ID 的方式指定使用哪一个渠道处理本次请求,例如:`Authorization: Bearer ONE_API_KEY-CHANNEL_ID`。 | ||||
| @@ -272,32 +328,54 @@ graph LR | ||||
| 不加的话将会使用负载均衡的方式使用多个渠道。 | ||||
|  | ||||
| ### 环境变量 | ||||
| 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,请使用 MySQL 8.0 版本。 | ||||
|    + 例子:`SQL_DSN=root:123456@tcp(localhost:3306)/oneapi` | ||||
| 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` | ||||
| 6. `NODE_TYPE`:设置之后将指定节点类型,可选值为 `master` 和 `slave`,未设置则默认为 `master`。 | ||||
| 7. `NODE_TYPE`:设置之后将指定节点类型,可选值为 `master` 和 `slave`,未设置则默认为 `master`。 | ||||
|    + 例子:`NODE_TYPE=slave` | ||||
| 7. `CHANNEL_UPDATE_FREQUENCY`:设置之后将定期更新渠道余额,单位为分钟,未设置则不进行更新。 | ||||
| 8. `CHANNEL_UPDATE_FREQUENCY`:设置之后将定期更新渠道余额,单位为分钟,未设置则不进行更新。 | ||||
|    + 例子:`CHANNEL_UPDATE_FREQUENCY=1440` | ||||
| 8. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。 | ||||
| 9. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。 | ||||
|    + 例子:`CHANNEL_TEST_FREQUENCY=1440` | ||||
| 9. `POLLING_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。 | ||||
|    + 例子:`POLLING_INTERVAL=5` | ||||
| 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 <port_number>`: 指定服务器监听的端口号,默认为 `3000`。 | ||||
|    + 例子:`--port 3000` | ||||
| 2. `--log-dir <log_dir>`: 指定日志文件夹,如果没有设置,日志将不会被保存。 | ||||
| 2. `--log-dir <log_dir>`: 指定日志文件夹,如果没有设置,默认保存至工作目录的 `logs` 文件夹下。 | ||||
|    + 例子:`--log-dir ./logs` | ||||
| 3. `--version`: 打印系统版本号并退出。 | ||||
| 4. `--help`: 查看命令的使用帮助和参数说明。 | ||||
| @@ -329,11 +407,19 @@ https://openai.justsong.cn | ||||
| 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/c121914yu/FastGPT): 三分钟搭建 AI 知识库 | ||||
| * [FastGPT](https://github.com/labring/FastGPT): 基于 LLM 大语言模型的知识库问答系统 | ||||
| * [ChatGPT Next Web](https://github.com/Yidadaa/ChatGPT-Next-Web):  一键拥有你自己的跨平台 ChatGPT 应用 | ||||
|  | ||||
| ## 注意 | ||||
|  | ||||
| @@ -341,4 +427,4 @@ https://openai.justsong.cn | ||||
|  | ||||
| 同样适用于基于本项目的二开项目。 | ||||
|  | ||||
| 依据 MIT 协议,使用者需自行承担使用本项目的风险与责任,本开源项目开发者与此无关。 | ||||
| 依据 MIT 协议,使用者需自行承担使用本项目的风险与责任,本开源项目开发者与此无关。 | ||||
|   | ||||
| @@ -21,12 +21,9 @@ var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens | ||||
| var DisplayInCurrencyEnabled = true | ||||
| var DisplayTokenStatEnabled = true | ||||
|  | ||||
| var UsingSQLite = false | ||||
|  | ||||
| // 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 | ||||
| @@ -55,6 +52,9 @@ var EmailDomainWhitelist = []string{ | ||||
| 	"foxmail.com", | ||||
| } | ||||
|  | ||||
| var DebugEnabled = os.Getenv("DEBUG") == "true" | ||||
| var MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true" | ||||
|  | ||||
| var LogConsumeEnabled = true | ||||
|  | ||||
| var SMTPServer = "" | ||||
| @@ -90,7 +90,16 @@ var IsMasterNode = os.Getenv("NODE_TYPE") != "slave" | ||||
| var requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) | ||||
| var RequestInterval = time.Duration(requestInterval) * time.Second | ||||
|  | ||||
| var SyncFrequency = 10 * 60 // unit is second, will be overwritten by SYNC_FREQUENCY | ||||
| 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 | ||||
| @@ -109,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 | ||||
| @@ -146,51 +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 | ||||
| 	ChannelTypeAPI2GPT   = 12 | ||||
| 	ChannelTypeAIGC2D    = 13 | ||||
| 	ChannelTypeAnthropic = 14 | ||||
| 	ChannelTypeBaidu     = 15 | ||||
| 	ChannelTypeZhipu     = 16 | ||||
| 	ChannelTypeAli       = 17 | ||||
| 	ChannelTypeXunfei    = 18 | ||||
| 	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.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 | ||||
| 	"",                                  // 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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										6
									
								
								common/database.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								common/database.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| package common | ||||
|  | ||||
| var UsingSQLite = false | ||||
| var UsingPostgreSQL = false | ||||
|  | ||||
| var SQLitePath = "one-api.db" | ||||
| @@ -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() { | ||||
|   | ||||
| @@ -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,6 +55,36 @@ 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) | ||||
|   | ||||
| @@ -1,6 +1,10 @@ | ||||
| package common | ||||
|  | ||||
| import "encoding/json" | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| // ModelRatio | ||||
| // https://platform.openai.com/docs/models/model-endpoint-compatibility | ||||
| @@ -10,46 +14,58 @@ import "encoding/json" | ||||
| // 1 === $0.002 / 1K tokens | ||||
| // 1 === ¥0.014 / 1k tokens | ||||
| var ModelRatio = map[string]float64{ | ||||
| 	"gpt-4":                   15, | ||||
| 	"gpt-4-0314":              15, | ||||
| 	"gpt-4-0613":              15, | ||||
| 	"gpt-4-32k":               30, | ||||
| 	"gpt-4-32k-0314":          30, | ||||
| 	"gpt-4-32k-0613":          30, | ||||
| 	"gpt-3.5-turbo":           0.75, // $0.0015 / 1K tokens | ||||
| 	"gpt-3.5-turbo-0301":      0.75, | ||||
| 	"gpt-3.5-turbo-0613":      0.75, | ||||
| 	"gpt-3.5-turbo-16k":       1.5, // $0.003 / 1K tokens | ||||
| 	"gpt-3.5-turbo-16k-0613":  1.5, | ||||
| 	"text-ada-001":            0.2, | ||||
| 	"text-babbage-001":        0.25, | ||||
| 	"text-curie-001":          1, | ||||
| 	"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.05, | ||||
| 	"text-search-ada-doc-001": 10, | ||||
| 	"text-moderation-stable":  0.1, | ||||
| 	"text-moderation-latest":  0.1, | ||||
| 	"dall-e":                  8, | ||||
| 	"claude-instant-1":        0.75, | ||||
| 	"claude-2":                30, | ||||
| 	"ERNIE-Bot":               0.8572, // ¥0.012 / 1k tokens | ||||
| 	"ERNIE-Bot-turbo":         0.5715, // ¥0.008 / 1k tokens | ||||
| 	"Embedding-V1":            0.1429, // ¥0.002 / 1k tokens | ||||
| 	"PaLM-2":                  1, | ||||
| 	"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-v1":                 0.8572, // TBD: https://help.aliyun.com/document_detail/2399482.html?spm=a2c4g.2399482.0.0.1ad347feilAgag | ||||
| 	"qwen-plus-v1":            0.5715, // Same as above | ||||
| 	"SparkDesk":               0.8572, // TBD | ||||
| 	"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 { | ||||
| @@ -73,3 +89,34 @@ func GetModelRatio(name string) float64 { | ||||
| 	} | ||||
| 	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 | ||||
| } | ||||
|   | ||||
| @@ -61,3 +61,8 @@ 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() | ||||
| } | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import ( | ||||
| 	"log" | ||||
| 	"math/rand" | ||||
| 	"net" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"runtime" | ||||
| 	"strconv" | ||||
| @@ -170,6 +171,11 @@ 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 | ||||
| @@ -177,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 | ||||
| } | ||||
|   | ||||
| @@ -29,7 +29,7 @@ func GetSubscription(c *gin.Context) { | ||||
| 	if err != nil { | ||||
| 		openAIError := OpenAIError{ | ||||
| 			Message: err.Error(), | ||||
| 			Type:    "one_api_error", | ||||
| 			Type:    "upstream_error", | ||||
| 		} | ||||
| 		c.JSON(200, gin.H{ | ||||
| 			"error": openAIError, | ||||
|   | ||||
| @@ -111,7 +111,7 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He | ||||
| } | ||||
|  | ||||
| func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) { | ||||
| 	url := fmt.Sprintf("%s/dashboard/billing/credit_grants", channel.BaseURL) | ||||
| 	url := fmt.Sprintf("%s/dashboard/billing/credit_grants", channel.GetBaseURL()) | ||||
| 	body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) | ||||
|  | ||||
| 	if err != nil { | ||||
| @@ -201,18 +201,18 @@ func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) { | ||||
|  | ||||
| func updateChannelBalance(channel *model.Channel) (float64, error) { | ||||
| 	baseURL := common.ChannelBaseURLs[channel.Type] | ||||
| 	if channel.BaseURL == "" { | ||||
| 		channel.BaseURL = baseURL | ||||
| 	if channel.GetBaseURL() == "" { | ||||
| 		channel.BaseURL = &baseURL | ||||
| 	} | ||||
| 	switch channel.Type { | ||||
| 	case common.ChannelTypeOpenAI: | ||||
| 		if channel.BaseURL != "" { | ||||
| 			baseURL = channel.BaseURL | ||||
| 		if channel.GetBaseURL() != "" { | ||||
| 			baseURL = channel.GetBaseURL() | ||||
| 		} | ||||
| 	case common.ChannelTypeAzure: | ||||
| 		return 0, errors.New("尚未实现") | ||||
| 	case common.ChannelTypeCustom: | ||||
| 		baseURL = channel.BaseURL | ||||
| 		baseURL = channel.GetBaseURL() | ||||
| 	case common.ChannelTypeCloseAI: | ||||
| 		return updateChannelCloseAIBalance(channel) | ||||
| 	case common.ChannelTypeOpenAISB: | ||||
|   | ||||
| @@ -10,11 +10,12 @@ import ( | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func testChannel(channel *model.Channel, request ChatRequest) (error, *OpenAIError) { | ||||
| func testChannel(channel *model.Channel, request ChatRequest) (err error, openaiErr *OpenAIError) { | ||||
| 	switch channel.Type { | ||||
| 	case common.ChannelTypePaLM: | ||||
| 		fallthrough | ||||
| @@ -24,22 +25,33 @@ func testChannel(channel *model.Channel, request ChatRequest) (error, *OpenAIErr | ||||
| 		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" | ||||
| 	} | ||||
| 	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.BaseURL != "" { | ||||
| 			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 { | ||||
| @@ -132,7 +144,7 @@ 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) | ||||
| @@ -174,7 +186,7 @@ func testAllChannels(notify bool) error { | ||||
| 				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) { | ||||
| 			if shouldDisableChannel(openaiErr, -1) { | ||||
| 				disableChannel(channel.Id, channel.Name, err.Error()) | ||||
| 			} | ||||
| 			channel.UpdateResponseTime(milliseconds) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
| @@ -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, | ||||
| 	}) | ||||
| } | ||||
|   | ||||
| @@ -2,6 +2,7 @@ package controller | ||||
|  | ||||
| import ( | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
| 	"strconv" | ||||
| @@ -18,19 +19,21 @@ func GetAllLogs(c *gin.Context) { | ||||
| 	username := c.Query("username") | ||||
| 	tokenName := c.Query("token_name") | ||||
| 	modelName := c.Query("model_name") | ||||
| 	logs, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, p*common.ItemsPerPage, common.ItemsPerPage) | ||||
| 	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(200, gin.H{ | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	c.JSON(200, gin.H{ | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "", | ||||
| 		"data":    logs, | ||||
| 	}) | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func GetUserLogs(c *gin.Context) { | ||||
| @@ -46,34 +49,36 @@ func GetUserLogs(c *gin.Context) { | ||||
| 	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(200, gin.H{ | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	c.JSON(200, gin.H{ | ||||
| 	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(200, gin.H{ | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	c.JSON(200, gin.H{ | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "", | ||||
| 		"data":    logs, | ||||
| 	}) | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func SearchUserLogs(c *gin.Context) { | ||||
| @@ -81,17 +86,18 @@ func SearchUserLogs(c *gin.Context) { | ||||
| 	userId := c.GetInt("id") | ||||
| 	logs, err := model.SearchUserLogs(userId, keyword) | ||||
| 	if err != nil { | ||||
| 		c.JSON(200, gin.H{ | ||||
| 		c.JSON(http.StatusOK, gin.H{ | ||||
| 			"success": false, | ||||
| 			"message": err.Error(), | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 	c.JSON(200, gin.H{ | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "", | ||||
| 		"data":    logs, | ||||
| 	}) | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func GetLogsStat(c *gin.Context) { | ||||
| @@ -101,9 +107,10 @@ func GetLogsStat(c *gin.Context) { | ||||
| 	tokenName := c.Query("token_name") | ||||
| 	username := c.Query("username") | ||||
| 	modelName := c.Query("model_name") | ||||
| 	quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName) | ||||
| 	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(200, gin.H{ | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "", | ||||
| 		"data": gin.H{ | ||||
| @@ -111,6 +118,7 @@ func GetLogsStat(c *gin.Context) { | ||||
| 			//"token": tokenNum, | ||||
| 		}, | ||||
| 	}) | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func GetLogsSelfStat(c *gin.Context) { | ||||
| @@ -120,9 +128,10 @@ func GetLogsSelfStat(c *gin.Context) { | ||||
| 	endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) | ||||
| 	tokenName := c.Query("token_name") | ||||
| 	modelName := c.Query("model_name") | ||||
| 	quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName) | ||||
| 	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(200, gin.H{ | ||||
| 	c.JSON(http.StatusOK, gin.H{ | ||||
| 		"success": true, | ||||
| 		"message": "", | ||||
| 		"data": gin.H{ | ||||
| @@ -130,4 +139,30 @@ func GetLogsSelfStat(c *gin.Context) { | ||||
| 			//"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 | ||||
| } | ||||
|   | ||||
| @@ -63,6 +63,15 @@ func init() { | ||||
| 			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", | ||||
| @@ -108,6 +117,24 @@ func init() { | ||||
| 			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", | ||||
| @@ -162,6 +189,24 @@ func init() { | ||||
| 			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, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "text-embedding-ada-002", | ||||
| 			Object:     "model", | ||||
| @@ -256,7 +301,7 @@ func init() { | ||||
| 			Id:         "claude-instant-1", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "anturopic", | ||||
| 			OwnedBy:    "anthropic", | ||||
| 			Permission: permission, | ||||
| 			Root:       "claude-instant-1", | ||||
| 			Parent:     nil, | ||||
| @@ -265,7 +310,7 @@ func init() { | ||||
| 			Id:         "claude-2", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "anturopic", | ||||
| 			OwnedBy:    "anthropic", | ||||
| 			Permission: permission, | ||||
| 			Root:       "claude-2", | ||||
| 			Parent:     nil, | ||||
| @@ -288,6 +333,15 @@ func init() { | ||||
| 			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", | ||||
| @@ -306,6 +360,15 @@ func init() { | ||||
| 			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", | ||||
| @@ -334,21 +397,30 @@ func init() { | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "qwen-v1", | ||||
| 			Id:         "qwen-turbo", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "ali", | ||||
| 			Permission: permission, | ||||
| 			Root:       "qwen-v1", | ||||
| 			Root:       "qwen-turbo", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "qwen-plus-v1", | ||||
| 			Id:         "qwen-plus", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "ali", | ||||
| 			Permission: permission, | ||||
| 			Root:       "qwen-plus-v1", | ||||
| 			Root:       "qwen-plus", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Id:         "text-embedding-v1", | ||||
| 			Object:     "model", | ||||
| 			Created:    1677649963, | ||||
| 			OwnedBy:    "ali", | ||||
| 			Permission: permission, | ||||
| 			Root:       "text-embedding-v1", | ||||
| 			Parent:     nil, | ||||
| 		}, | ||||
| 		{ | ||||
| @@ -360,6 +432,51 @@ func init() { | ||||
| 			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 { | ||||
|   | ||||
| @@ -46,7 +46,7 @@ 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 | ||||
| 		} | ||||
|   | ||||
							
								
								
									
										220
									
								
								controller/relay-aiproxy.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								controller/relay-aiproxy.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -35,6 +35,29 @@ type AliChatRequest struct { | ||||
| 	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"` | ||||
| @@ -44,6 +67,7 @@ type AliError struct { | ||||
| type AliUsage struct { | ||||
| 	InputTokens  int `json:"input_tokens"` | ||||
| 	OutputTokens int `json:"output_tokens"` | ||||
| 	TotalTokens  int `json:"total_tokens"` | ||||
| } | ||||
|  | ||||
| type AliOutput struct { | ||||
| @@ -95,6 +119,70 @@ func requestOpenAI2Ali(request GeneralOpenAIRequest) *AliChatRequest { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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, | ||||
| @@ -121,7 +209,10 @@ func responseAli2OpenAI(response *AliChatResponse) *OpenAITextResponse { | ||||
| func streamResponseAli2OpenAI(aliResponse *AliChatResponse) *ChatCompletionsStreamResponse { | ||||
| 	var choice ChatCompletionsStreamResponseChoice | ||||
| 	choice.Delta.Content = aliResponse.Output.Text | ||||
| 	choice.FinishReason = aliResponse.Output.FinishReason | ||||
| 	if aliResponse.Output.FinishReason != "null" { | ||||
| 		finishReason := aliResponse.Output.FinishReason | ||||
| 		choice.FinishReason = &finishReason | ||||
| 	} | ||||
| 	response := ChatCompletionsStreamResponse{ | ||||
| 		Id:      aliResponse.RequestId, | ||||
| 		Object:  "chat.completion.chunk", | ||||
| @@ -163,11 +254,7 @@ func aliStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStat | ||||
| 		} | ||||
| 		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") | ||||
| 	setEventStreamHeaders(c) | ||||
| 	lastResponseText := "" | ||||
| 	c.Stream(func(w io.Writer) bool { | ||||
| 		select { | ||||
| @@ -178,9 +265,11 @@ func aliStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStat | ||||
| 				common.SysError("error unmarshalling stream response: " + err.Error()) | ||||
| 				return true | ||||
| 			} | ||||
| 			usage.PromptTokens += aliResponse.Usage.InputTokens | ||||
| 			usage.CompletionTokens += aliResponse.Usage.OutputTokens | ||||
| 			usage.TotalTokens += aliResponse.Usage.InputTokens + aliResponse.Usage.OutputTokens | ||||
| 			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 | ||||
|   | ||||
							
								
								
									
										151
									
								
								controller/relay-audio.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								controller/relay-audio.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -3,22 +3,22 @@ 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 { | ||||
| 	RefreshToken  string `json:"refresh_token"` | ||||
| 	ExpiresIn     int    `json:"expires_in"` | ||||
| 	SessionKey    string `json:"session_key"` | ||||
| 	AccessToken   string `json:"access_token"` | ||||
| 	Scope         string `json:"scope"` | ||||
| 	SessionSecret string `json:"session_secret"` | ||||
| 	ExpiresIn   int    `json:"expires_in"` | ||||
| 	AccessToken string `json:"access_token"` | ||||
| } | ||||
|  | ||||
| type BaiduMessage struct { | ||||
| @@ -73,6 +73,16 @@ type BaiduEmbeddingResponse struct { | ||||
| 	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 { | ||||
| @@ -120,7 +130,9 @@ func responseBaidu2OpenAI(response *BaiduChatResponse) *OpenAITextResponse { | ||||
| func streamResponseBaidu2OpenAI(baiduResponse *BaiduChatStreamResponse) *ChatCompletionsStreamResponse { | ||||
| 	var choice ChatCompletionsStreamResponseChoice | ||||
| 	choice.Delta.Content = baiduResponse.Result | ||||
| 	choice.FinishReason = "stop" | ||||
| 	if baiduResponse.IsEnd { | ||||
| 		choice.FinishReason = &stopFinishReason | ||||
| 	} | ||||
| 	response := ChatCompletionsStreamResponse{ | ||||
| 		Id:      baiduResponse.Id, | ||||
| 		Object:  "chat.completion.chunk", | ||||
| @@ -132,16 +144,9 @@ func streamResponseBaidu2OpenAI(baiduResponse *BaiduChatStreamResponse) *ChatCom | ||||
| } | ||||
|  | ||||
| func embeddingRequestOpenAI2Baidu(request GeneralOpenAIRequest) *BaiduEmbeddingRequest { | ||||
| 	baiduEmbeddingRequest := BaiduEmbeddingRequest{ | ||||
| 		Input: nil, | ||||
| 	return &BaiduEmbeddingRequest{ | ||||
| 		Input: request.ParseInput(), | ||||
| 	} | ||||
| 	switch request.Input.(type) { | ||||
| 	case string: | ||||
| 		baiduEmbeddingRequest.Input = []string{request.Input.(string)} | ||||
| 	case []string: | ||||
| 		baiduEmbeddingRequest.Input = request.Input.([]string) | ||||
| 	} | ||||
| 	return &baiduEmbeddingRequest | ||||
| } | ||||
|  | ||||
| func embeddingResponseBaidu2OpenAI(response *BaiduEmbeddingResponse) *OpenAIEmbeddingResponse { | ||||
| @@ -189,11 +194,7 @@ func baiduStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithSt | ||||
| 		} | ||||
| 		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") | ||||
| 	setEventStreamHeaders(c) | ||||
| 	c.Stream(func(w io.Writer) bool { | ||||
| 		select { | ||||
| 		case data := <-dataChan: | ||||
| @@ -203,9 +204,11 @@ func baiduStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithSt | ||||
| 				common.SysError("error unmarshalling stream response: " + err.Error()) | ||||
| 				return true | ||||
| 			} | ||||
| 			usage.PromptTokens += baiduResponse.Usage.PromptTokens | ||||
| 			usage.CompletionTokens += baiduResponse.Usage.CompletionTokens | ||||
| 			usage.TotalTokens += baiduResponse.Usage.TotalTokens | ||||
| 			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 { | ||||
| @@ -297,3 +300,60 @@ func baiduEmbeddingHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWit | ||||
| 	_, 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 | ||||
| } | ||||
|   | ||||
| @@ -81,7 +81,10 @@ func requestOpenAI2Claude(textRequest GeneralOpenAIRequest) *ClaudeRequest { | ||||
| func streamResponseClaude2OpenAI(claudeResponse *ClaudeResponse) *ChatCompletionsStreamResponse { | ||||
| 	var choice ChatCompletionsStreamResponseChoice | ||||
| 	choice.Delta.Content = claudeResponse.Completion | ||||
| 	choice.FinishReason = stopReasonClaude2OpenAI(claudeResponse.StopReason) | ||||
| 	finishReason := stopReasonClaude2OpenAI(claudeResponse.StopReason) | ||||
| 	if finishReason != "null" { | ||||
| 		choice.FinishReason = &finishReason | ||||
| 	} | ||||
| 	var response ChatCompletionsStreamResponse | ||||
| 	response.Object = "chat.completion.chunk" | ||||
| 	response.Model = claudeResponse.Model | ||||
| @@ -138,11 +141,7 @@ func claudeStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithS | ||||
| 		} | ||||
| 		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") | ||||
| 	setEventStreamHeaders(c) | ||||
| 	c.Stream(func(w io.Writer) bool { | ||||
| 		select { | ||||
| 		case data := <-dataChan: | ||||
|   | ||||
| @@ -2,15 +2,15 @@ package controller | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"one-api/common" | ||||
| 	"one-api/model" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| ) | ||||
|  | ||||
| func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| @@ -18,6 +18,7 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode | ||||
|  | ||||
| 	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") | ||||
| @@ -59,16 +60,12 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode | ||||
| 			isModelMapped = true | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	baseURL := common.ChannelBaseURLs[channelType] | ||||
| 	requestURL := c.Request.URL.String() | ||||
|  | ||||
| 	if c.GetString("base_url") != "" { | ||||
| 		baseURL = c.GetString("base_url") | ||||
| 	} | ||||
|  | ||||
| 	fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) | ||||
|  | ||||
| 	fullRequestURL := getFullRequestURL(baseURL, requestURL, channelType) | ||||
| 	var requestBody io.Reader | ||||
| 	if isModelMapped { | ||||
| 		jsonStr, err := json.Marshal(imageRequest) | ||||
| @@ -97,7 +94,7 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode | ||||
| 	quota := int(ratio*sizeRatio*1000) * imageRequest.N | ||||
|  | ||||
| 	if consumeQuota && userQuota-quota < 0 { | ||||
| 		return errorWrapper(err, "insufficient_user_quota", http.StatusForbidden) | ||||
| 		return errorWrapper(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden) | ||||
| 	} | ||||
|  | ||||
| 	req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) | ||||
| @@ -124,7 +121,7 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode | ||||
| 	} | ||||
| 	var textResponse ImageResponse | ||||
|  | ||||
| 	defer func() { | ||||
| 	defer func(ctx context.Context) { | ||||
| 		if consumeQuota { | ||||
| 			err := model.PostConsumeTokenQuota(tokenId, quota) | ||||
| 			if err != nil { | ||||
| @@ -137,13 +134,13 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode | ||||
| 			if quota != 0 { | ||||
| 				tokenName := c.GetString("token_name") | ||||
| 				logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio) | ||||
| 				model.RecordConsumeLog(userId, 0, 0, imageModel, tokenName, quota, logContent) | ||||
| 				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) | ||||
|   | ||||
| @@ -66,11 +66,7 @@ func openaiStreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*O | ||||
| 		} | ||||
| 		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") | ||||
| 	setEventStreamHeaders(c) | ||||
| 	c.Stream(func(w io.Writer) bool { | ||||
| 		select { | ||||
| 		case data := <-dataChan: | ||||
|   | ||||
| @@ -94,7 +94,7 @@ func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *ChatCompletionsS | ||||
| 	if len(palmResponse.Candidates) > 0 { | ||||
| 		choice.Delta.Content = palmResponse.Candidates[0].Content | ||||
| 	} | ||||
| 	choice.FinishReason = "stop" | ||||
| 	choice.FinishReason = &stopFinishReason | ||||
| 	var response ChatCompletionsStreamResponse | ||||
| 	response.Object = "chat.completion.chunk" | ||||
| 	response.Model = "palm2" | ||||
| @@ -143,11 +143,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithSta | ||||
| 		dataChan <- string(jsonResponse) | ||||
| 		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") | ||||
| 	setEventStreamHeaders(c) | ||||
| 	c.Stream(func(w io.Writer) bool { | ||||
| 		select { | ||||
| 		case data := <-dataChan: | ||||
|   | ||||
							
								
								
									
										287
									
								
								controller/relay-tencent.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								controller/relay-tencent.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
| @@ -2,14 +2,17 @@ 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" | ||||
| ) | ||||
| @@ -22,16 +25,30 @@ const ( | ||||
| 	APITypeZhipu | ||||
| 	APITypeAli | ||||
| 	APITypeXunfei | ||||
| 	APITypeAIProxyLibrary | ||||
| 	APITypeTencent | ||||
| ) | ||||
|  | ||||
| var httpClient *http.Client | ||||
| var impatientHTTPClient *http.Client | ||||
|  | ||||
| func init() { | ||||
| 	httpClient = &http.Client{} | ||||
| 	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") | ||||
| @@ -100,13 +117,17 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		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 := fmt.Sprintf("%s%s", baseURL, requestURL) | ||||
| 	fullRequestURL := getFullRequestURL(baseURL, requestURL, channelType) | ||||
| 	switch apiType { | ||||
| 	case APITypeOpenAI: | ||||
| 		if channelType == common.ChannelTypeAzure { | ||||
| @@ -139,6 +160,8 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			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": | ||||
| @@ -146,7 +169,11 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		} | ||||
| 		apiKey := c.Request.Header.Get("Authorization") | ||||
| 		apiKey = strings.TrimPrefix(apiKey, "Bearer ") | ||||
| 		fullRequestURL += "?access_token=" + apiKey // TODO: access token expire in 30 days | ||||
| 		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 != "" { | ||||
| @@ -163,6 +190,13 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		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 | ||||
| @@ -186,10 +220,18 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError) | ||||
| 	} | ||||
| 	if userQuota > 10*preConsumedQuota { | ||||
| 	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) | ||||
| @@ -245,8 +287,41 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 		} | ||||
| 		requestBody = bytes.NewBuffer(jsonStr) | ||||
| 	case APITypeAli: | ||||
| 		aliRequest := requestOpenAI2Ali(textRequest) | ||||
| 		jsonStr, err := json.Marshal(aliRequest) | ||||
| 		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) | ||||
| 		} | ||||
| @@ -270,6 +345,10 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 				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) | ||||
| @@ -286,9 +365,16 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			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 { | ||||
| @@ -303,54 +389,60 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			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() { | ||||
| 		if consumeQuota { | ||||
| 			quota := 0 | ||||
| 			completionRatio := 1.0 | ||||
| 			if strings.HasPrefix(textRequest.Model, "gpt-3.5") { | ||||
| 				completionRatio = 1.333333 | ||||
| 	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) | ||||
| 				} | ||||
| 			} | ||||
| 			if strings.HasPrefix(textRequest.Model, "gpt-4") { | ||||
| 				completionRatio = 2 | ||||
| 			} | ||||
|  | ||||
| 			promptTokens = textResponse.Usage.PromptTokens | ||||
| 			completionTokens = textResponse.Usage.CompletionTokens | ||||
|  | ||||
| 			quota = promptTokens + int(float64(completionTokens)*completionRatio) | ||||
| 			quota = int(float64(quota) * 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.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(userId, promptTokens, completionTokens, textRequest.Model, tokenName, quota, logContent) | ||||
| 				model.UpdateUserUsedQuotaAndRequestCount(userId, quota) | ||||
| 				channelId := c.GetInt("channel_id") | ||||
| 				model.UpdateChannelUsedQuota(channelId, quota) | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| 		}() | ||||
| 	}(c.Request.Context()) | ||||
| 	switch apiType { | ||||
| 	case APITypeOpenAI: | ||||
| 		if isStream { | ||||
| @@ -471,7 +563,14 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			} | ||||
| 			return nil | ||||
| 		} else { | ||||
| 			err, usage := aliHandler(c, resp) | ||||
| 			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 | ||||
| 			} | ||||
| @@ -481,14 +580,29 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			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 { | ||||
| 			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) | ||||
| 			} | ||||
| 			err, usage := xunfeiStreamHandler(c, textRequest, splits[0], splits[1], splits[2]) | ||||
| 			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 | ||||
| 			} | ||||
| @@ -497,7 +611,33 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode { | ||||
| 			} | ||||
| 			return nil | ||||
| 		} else { | ||||
| 			return errorWrapper(errors.New("xunfei api does not support non-stream mode"), "invalid_api_type", http.StatusBadRequest) | ||||
| 			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) | ||||
|   | ||||
| @@ -1,27 +1,61 @@ | ||||
| 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 { | ||||
| @@ -92,15 +126,63 @@ func errorWrapper(err error, code string, statusCode int) *OpenAIErrorWithStatus | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func shouldDisableChannel(err *OpenAIError) bool { | ||||
| 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 | ||||
| } | ||||
|   | ||||
| @@ -75,7 +75,7 @@ type XunfeiChatResponse struct { | ||||
| 	} `json:"payload"` | ||||
| } | ||||
|  | ||||
| func requestOpenAI2Xunfei(request GeneralOpenAIRequest, xunfeiAppId string) *XunfeiChatRequest { | ||||
| 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" { | ||||
| @@ -96,7 +96,7 @@ func requestOpenAI2Xunfei(request GeneralOpenAIRequest, xunfeiAppId string) *Xun | ||||
| 	} | ||||
| 	xunfeiRequest := XunfeiChatRequest{} | ||||
| 	xunfeiRequest.Header.AppId = xunfeiAppId | ||||
| 	xunfeiRequest.Parameter.Chat.Domain = "general" | ||||
| 	xunfeiRequest.Parameter.Chat.Domain = domain | ||||
| 	xunfeiRequest.Parameter.Chat.Temperature = request.Temperature | ||||
| 	xunfeiRequest.Parameter.Chat.TopK = request.N | ||||
| 	xunfeiRequest.Parameter.Chat.MaxTokens = request.MaxTokens | ||||
| @@ -118,6 +118,7 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *OpenAITextResponse { | ||||
| 			Role:    "assistant", | ||||
| 			Content: response.Payload.Choices.Text[0].Content, | ||||
| 		}, | ||||
| 		FinishReason: stopFinishReason, | ||||
| 	} | ||||
| 	fullTextResponse := OpenAITextResponse{ | ||||
| 		Object:  "chat.completion", | ||||
| @@ -138,6 +139,9 @@ func streamResponseXunfei2OpenAI(xunfeiResponse *XunfeiChatResponse) *ChatComple | ||||
| 	} | ||||
| 	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(), | ||||
| @@ -174,20 +178,85 @@ func buildXunfeiAuthUrl(hostUrl string, apiKey, apiSecret string) string { | ||||
| } | ||||
|  | ||||
| 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, | ||||
| 	} | ||||
| 	hostUrl := "wss://aichat.xf-yun.com/v1/chat" | ||||
| 	conn, resp, err := d.Dial(buildXunfeiAuthUrl(hostUrl, apiKey, apiSecret), nil) | ||||
| 	conn, resp, err := d.Dial(authUrl, nil) | ||||
| 	if err != nil || resp.StatusCode != 101 { | ||||
| 		return errorWrapper(err, "dial_failed", http.StatusInternalServerError), nil | ||||
| 		return nil, nil, err | ||||
| 	} | ||||
| 	data := requestOpenAI2Xunfei(textRequest, appId) | ||||
| 	data := requestOpenAI2Xunfei(textRequest, appId, domain) | ||||
| 	err = conn.WriteJSON(data) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "write_json_failed", http.StatusInternalServerError), nil | ||||
| 		return nil, nil, err | ||||
| 	} | ||||
|  | ||||
| 	dataChan := make(chan XunfeiChatResponse) | ||||
| 	stopChan := make(chan bool) | ||||
| 	go func() { | ||||
| @@ -214,65 +283,24 @@ func xunfeiStreamHandler(c *gin.Context, textRequest GeneralOpenAIRequest, appId | ||||
| 		} | ||||
| 		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 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 | ||||
|  | ||||
| 	return dataChan, stopChan, nil | ||||
| } | ||||
|  | ||||
| func xunfeiHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithStatusCode, *Usage) { | ||||
| 	var xunfeiResponse XunfeiChatResponse | ||||
| 	responseBody, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), 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") | ||||
| 	} | ||||
| 	err = resp.Body.Close() | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	if apiVersion == "" { | ||||
| 		apiVersion = "v1.1" | ||||
| 		common.SysLog("api_version not found, use default: " + apiVersion) | ||||
| 	} | ||||
| 	err = json.Unmarshal(responseBody, &xunfeiResponse) | ||||
| 	if err != nil { | ||||
| 		return errorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil | ||||
| 	domain := "general" | ||||
| 	if apiVersion != "v1.1" { | ||||
| 		domain += strings.Split(apiVersion, ".")[0] | ||||
| 	} | ||||
| 	if xunfeiResponse.Header.Code != 0 { | ||||
| 		return &OpenAIErrorWithStatusCode{ | ||||
| 			OpenAIError: OpenAIError{ | ||||
| 				Message: xunfeiResponse.Header.Message, | ||||
| 				Type:    "xunfei_error", | ||||
| 				Param:   "", | ||||
| 				Code:    xunfeiResponse.Header.Code, | ||||
| 			}, | ||||
| 			StatusCode: resp.StatusCode, | ||||
| 		}, nil | ||||
| 	} | ||||
| 	fullTextResponse := responseXunfei2OpenAI(&xunfeiResponse) | ||||
| 	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 | ||||
| 	authUrl := buildXunfeiAuthUrl(fmt.Sprintf("wss://spark-api.xf-yun.com/%s/chat", apiVersion), apiKey, apiSecret) | ||||
| 	return domain, authUrl | ||||
| } | ||||
|   | ||||
| @@ -163,7 +163,6 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *OpenAITextResponse { | ||||
| func streamResponseZhipu2OpenAI(zhipuResponse string) *ChatCompletionsStreamResponse { | ||||
| 	var choice ChatCompletionsStreamResponseChoice | ||||
| 	choice.Delta.Content = zhipuResponse | ||||
| 	choice.FinishReason = "" | ||||
| 	response := ChatCompletionsStreamResponse{ | ||||
| 		Object:  "chat.completion.chunk", | ||||
| 		Created: common.GetTimestamp(), | ||||
| @@ -176,7 +175,7 @@ func streamResponseZhipu2OpenAI(zhipuResponse string) *ChatCompletionsStreamResp | ||||
| func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*ChatCompletionsStreamResponse, *Usage) { | ||||
| 	var choice ChatCompletionsStreamResponseChoice | ||||
| 	choice.Delta.Content = "" | ||||
| 	choice.FinishReason = "stop" | ||||
| 	choice.FinishReason = &stopFinishReason | ||||
| 	response := ChatCompletionsStreamResponse{ | ||||
| 		Id:      zhipuResponse.RequestId, | ||||
| 		Object:  "chat.completion.chunk", | ||||
| @@ -225,11 +224,7 @@ func zhipuStreamHandler(c *gin.Context, resp *http.Response) (*OpenAIErrorWithSt | ||||
| 		} | ||||
| 		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") | ||||
| 	setEventStreamHeaders(c) | ||||
| 	c.Stream(func(w io.Writer) bool { | ||||
| 		select { | ||||
| 		case data := <-dataChan: | ||||
|   | ||||
| @@ -24,6 +24,7 @@ const ( | ||||
| 	RelayModeModerations | ||||
| 	RelayModeImagesGenerations | ||||
| 	RelayModeEdits | ||||
| 	RelayModeAudio | ||||
| ) | ||||
|  | ||||
| // https://platform.openai.com/docs/api-reference/chat | ||||
| @@ -40,6 +41,26 @@ type GeneralOpenAIRequest struct { | ||||
| 	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 { | ||||
| @@ -62,6 +83,10 @@ type ImageRequest struct { | ||||
| 	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"` | ||||
| @@ -124,7 +149,7 @@ type ChatCompletionsStreamResponseChoice struct { | ||||
| 	Delta struct { | ||||
| 		Content string `json:"content"` | ||||
| 	} `json:"delta"` | ||||
| 	FinishReason string `json:"finish_reason,omitempty"` | ||||
| 	FinishReason *string `json:"finish_reason"` | ||||
| } | ||||
|  | ||||
| type ChatCompletionsStreamResponse struct { | ||||
| @@ -158,15 +183,20 @@ func Relay(c *gin.Context) { | ||||
| 		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 { | ||||
| 		requestId := c.GetString(common.RequestIdKey) | ||||
| 		retryTimesStr := c.Query("retry") | ||||
| 		retryTimes, _ := strconv.Atoi(retryTimesStr) | ||||
| 		if retryTimesStr == "" { | ||||
| @@ -176,16 +206,17 @@ func Relay(c *gin.Context) { | ||||
| 			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 = "当前分组上游负载已饱和,请稍后再试" | ||||
| 			} | ||||
| 			err.OpenAIError.Message = common.MessageWithRequestId(err.OpenAIError.Message, requestId) | ||||
| 			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 shouldDisableChannel(&err.OpenAIError) { | ||||
| 		if shouldDisableChannel(&err.OpenAIError, err.StatusCode) { | ||||
| 			channelId := c.GetInt("channel_id") | ||||
| 			channelName := c.GetString("channel_name") | ||||
| 			disableChannel(channelId, channelName, err.Message) | ||||
| @@ -207,10 +238,10 @@ func RelayNotImplemented(c *gin.Context) { | ||||
|  | ||||
| func RelayNotFound(c *gin.Context) { | ||||
| 	err := OpenAIError{ | ||||
| 		Message: fmt.Sprintf("API not found: %s:%s", c.Request.Method, c.Request.URL.Path), | ||||
| 		Type:    "one_api_error", | ||||
| 		Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), | ||||
| 		Type:    "invalid_request_error", | ||||
| 		Param:   "", | ||||
| 		Code:    "api_not_found", | ||||
| 		Code:    "", | ||||
| 	} | ||||
| 	c.JSON(http.StatusNotFound, gin.H{ | ||||
| 		"error": err, | ||||
|   | ||||
| @@ -109,10 +109,10 @@ 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 | ||||
| 	} | ||||
| @@ -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{ | ||||
|   | ||||
| @@ -9,21 +9,21 @@ services: | ||||
|     ports: | ||||
|       - "3000:3000" | ||||
|     volumes: | ||||
|       - ./data:/data | ||||
|       - ./data/oneapi:/data | ||||
|       - ./logs:/app/logs | ||||
|     environment: | ||||
|       - SQL_DSN=root:123456@tcp(host.docker.internal:3306)/one-api  # 修改此行,或注释掉以使用 SQLite 作为数据库 | ||||
|       - 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 | ||||
| @@ -32,3 +32,18 @@ services: | ||||
|     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   # 自动创建数据库 | ||||
							
								
								
									
										18
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								go.mod
									
									
									
									
									
								
							| @@ -14,11 +14,12 @@ require ( | ||||
| 	github.com/golang-jwt/jwt v3.2.2+incompatible | ||||
| 	github.com/google/uuid v1.3.0 | ||||
| 	github.com/gorilla/websocket v1.5.0 | ||||
| 	github.com/pkoukk/tiktoken-go v0.1.1 | ||||
| 	golang.org/x/crypto v0.9.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 ( | ||||
| @@ -26,7 +27,7 @@ require ( | ||||
| 	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 | ||||
| @@ -36,6 +37,9 @@ require ( | ||||
| 	github.com/gorilla/context v1.1.1 // indirect | ||||
| 	github.com/gorilla/securecookie v1.1.1 // indirect | ||||
| 	github.com/gorilla/sessions v1.2.1 // indirect | ||||
| 	github.com/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 | ||||
| @@ -49,9 +53,9 @@ require ( | ||||
| 	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.10.0 // indirect | ||||
| 	golang.org/x/sys v0.8.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 | ||||
| ) | ||||
|   | ||||
							
								
								
									
										35
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								go.sum
									
									
									
									
									
								
							| @@ -12,8 +12,8 @@ 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= | ||||
| @@ -69,6 +69,12 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg | ||||
| github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= | ||||
| github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= | ||||
| github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | ||||
| github.com/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= | ||||
| @@ -112,8 +118,8 @@ github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZO | ||||
| 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= | ||||
| @@ -144,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.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= | ||||
| golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= | ||||
| 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.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= | ||||
| golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= | ||||
| 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= | ||||
| @@ -156,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.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= | ||||
| golang.org/x/sys v0.8.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= | ||||
| @@ -187,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= | ||||
|   | ||||
							
								
								
									
										14
									
								
								i18n/en.json
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								i18n/en.json
									
									
									
									
									
								
							| @@ -39,8 +39,8 @@ | ||||
|   "兑换码个数必须大于0": "The number of redemption codes must be greater than 0", | ||||
|   "一次兑换码批量生成的个数不能大于 100": "The number of redemption codes generated in a batch cannot be greater than 100", | ||||
|   "通过令牌「%s」使用模型 %s 消耗 %s(模型倍率 %.2f,分组倍率 %.2f)": "Using model %s with token %s consumes %s (model rate %.2f, group rate %.2f)", | ||||
|   "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。": "The current group load is saturated, please try again later, or upgrade your account to improve service quality.", | ||||
|   "令牌名称长度必须在1-20之间": "The length of the token name must be between 1-20", | ||||
|   "当前分组上游负载已饱和,请稍后再试": "The 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", | ||||
| @@ -229,7 +229,7 @@ | ||||
|   "已是最新版本": "Is the latest version", | ||||
|   "检查更新": "Check for updates", | ||||
|   "公告": "Announcement", | ||||
|   "在此输入新的公告内容": "Enter new announcement content here", | ||||
|   "在此输入新的公告内容,支持 Markdown & HTML 代码": "Enter the new announcement content here, supports Markdown & HTML code", | ||||
|   "保存公告": "Save Announcement", | ||||
|   "个性化设置": "Personalization Settings", | ||||
|   "系统名称": "System Name", | ||||
| @@ -518,5 +518,11 @@ | ||||
|   ",图片演示。": "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" | ||||
|   "此项可选,用于通过代理站来进行 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!" | ||||
| } | ||||
|   | ||||
							
								
								
									
										38
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								main.go
									
									
									
									
									
								
							| @@ -2,6 +2,7 @@ package main | ||||
|  | ||||
| import ( | ||||
| 	"embed" | ||||
| 	"fmt" | ||||
| 	"github.com/gin-contrib/sessions" | ||||
| 	"github.com/gin-contrib/sessions/cookie" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| @@ -21,11 +22,14 @@ 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 { | ||||
| @@ -47,18 +51,17 @@ func main() { | ||||
| 	// Initialize options | ||||
| 	model.InitOptionMap() | ||||
| 	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 os.Getenv("SYNC_FREQUENCY") != "" { | ||||
| 		frequency, err := strconv.Atoi(os.Getenv("SYNC_FREQUENCY")) | ||||
| 		if err != nil { | ||||
| 			common.FatalLog("failed to parse SYNC_FREQUENCY: " + err.Error()) | ||||
| 		} | ||||
| 		common.SyncFrequency = frequency | ||||
| 		go model.SyncOptions(frequency) | ||||
| 		if common.RedisEnabled { | ||||
| 			go model.SyncChannelCache(frequency) | ||||
| 		} | ||||
| 	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")) | ||||
| @@ -74,13 +77,20 @@ func main() { | ||||
| 		} | ||||
| 		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 | ||||
| 	store := cookie.NewStore([]byte(common.SessionSecret)) | ||||
| 	server.Use(sessions.Sessions("session", store)) | ||||
|   | ||||
| @@ -91,23 +91,16 @@ func TokenAuth() func(c *gin.Context) { | ||||
| 		key = parts[0] | ||||
| 		token, err := model.ValidateUserToken(key) | ||||
| 		if err != nil { | ||||
| 			c.JSON(http.StatusUnauthorized, gin.H{ | ||||
| 				"error": gin.H{ | ||||
| 					"message": err.Error(), | ||||
| 					"type":    "one_api_error", | ||||
| 				}, | ||||
| 			}) | ||||
| 			c.Abort() | ||||
| 			abortWithMessage(c, http.StatusUnauthorized, err.Error()) | ||||
| 			return | ||||
| 		} | ||||
| 		if !model.CacheIsUserEnabled(token.UserId) { | ||||
| 			c.JSON(http.StatusForbidden, 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) | ||||
| @@ -123,13 +116,7 @@ func TokenAuth() func(c *gin.Context) { | ||||
| 			if model.IsAdmin(token.UserId) { | ||||
| 				c.Set("channelId", parts[1]) | ||||
| 			} else { | ||||
| 				c.JSON(http.StatusForbidden, gin.H{ | ||||
| 					"error": gin.H{ | ||||
| 						"message": "普通用户不支持指定渠道", | ||||
| 						"type":    "one_api_error", | ||||
| 					}, | ||||
| 				}) | ||||
| 				c.Abort() | ||||
| 				abortWithMessage(c, http.StatusForbidden, "普通用户不支持指定渠道") | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
|   | ||||
| @@ -25,48 +25,27 @@ func Distribute() func(c *gin.Context) { | ||||
| 		if ok { | ||||
| 			id, err := strconv.Atoi(channelId.(string)) | ||||
| 			if err != nil { | ||||
| 				c.JSON(http.StatusBadRequest, 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(http.StatusBadRequest, 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(http.StatusForbidden, 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 | ||||
| 			err := common.UnmarshalBodyReusable(c, &modelRequest) | ||||
| 			var err error | ||||
| 			if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { | ||||
| 				err = common.UnmarshalBodyReusable(c, &modelRequest) | ||||
| 			} | ||||
| 			if err != nil { | ||||
| 				c.JSON(http.StatusBadRequest, 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") { | ||||
| @@ -84,6 +63,11 @@ func Distribute() func(c *gin.Context) { | ||||
| 					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) | ||||
| @@ -91,24 +75,23 @@ func Distribute() func(c *gin.Context) { | ||||
| 					common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) | ||||
| 					message = "数据库一致性已被破坏,请联系管理员" | ||||
| 				} | ||||
| 				c.JSON(http.StatusServiceUnavailable, gin.H{ | ||||
| 					"error": gin.H{ | ||||
| 						"message": message, | ||||
| 						"type":    "one_api_error", | ||||
| 					}, | ||||
| 				}) | ||||
| 				c.Abort() | ||||
| 				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.ModelMapping) | ||||
| 		c.Set("model_mapping", channel.GetModelMapping()) | ||||
| 		c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) | ||||
| 		c.Set("base_url", channel.BaseURL) | ||||
| 		if channel.Type == common.ChannelTypeAzure { | ||||
| 		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() | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										25
									
								
								middleware/logger.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								middleware/logger.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
| 		) | ||||
| 	})) | ||||
| } | ||||
							
								
								
									
										18
									
								
								middleware/request-id.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								middleware/request-id.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										17
									
								
								middleware/utils.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								middleware/utils.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
| @@ -10,15 +10,25 @@ type Ability struct { | ||||
| 	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 | ||||
| 	if common.UsingSQLite { | ||||
| 		err = DB.Where("`group` = ? and model = ? and enabled = 1", group, model).Order("RANDOM()").Limit(1).First(&ability).Error | ||||
| 	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 = DB.Where("`group` = ? and model = ? and enabled = 1", group, model).Order("RAND()").Limit(1).First(&ability).Error | ||||
| 		err = channelQuery.Order("RAND()").First(&ability).Error | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| @@ -40,6 +50,7 @@ func (channel *Channel) AddAbilities() error { | ||||
| 				Model:     model, | ||||
| 				ChannelId: channel.Id, | ||||
| 				Enabled:   channel.Status == common.ChannelStatusEnabled, | ||||
| 				Priority:  channel.Priority, | ||||
| 			} | ||||
| 			abilities = append(abilities, ability) | ||||
| 		} | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import ( | ||||
| 	"fmt" | ||||
| 	"math/rand" | ||||
| 	"one-api/common" | ||||
| 	"sort" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| @@ -20,14 +21,18 @@ var ( | ||||
| ) | ||||
|  | ||||
| func CacheGetTokenByKey(key string) (*Token, error) { | ||||
| 	keyCol := "`key`" | ||||
| 	if common.UsingPostgreSQL { | ||||
| 		keyCol = `"key"` | ||||
| 	} | ||||
| 	var token Token | ||||
| 	if !common.RedisEnabled { | ||||
| 		err := DB.Where("`key` = ?", key).First(&token).Error | ||||
| 		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("`key` = ?", key).First(&token).Error | ||||
| 		err := DB.Where(keyCol+" = ?", key).First(&token).Error | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @@ -95,23 +100,36 @@ func CacheUpdateUserQuota(id int) error { | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func CacheIsUserEnabled(userId int) bool { | ||||
| 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 { | ||||
| 		status := common.UserStatusDisabled | ||||
| 		if IsUserEnabled(userId) { | ||||
| 			status = common.UserStatusEnabled | ||||
| 		} | ||||
| 		enabled = fmt.Sprintf("%d", status) | ||||
| 		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()) | ||||
| 		} | ||||
| 	if err == nil { | ||||
| 		return enabled == "1", nil | ||||
| 	} | ||||
| 	return enabled == "1" | ||||
|  | ||||
| 	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 | ||||
| @@ -146,6 +164,17 @@ func InitChannelCache() { | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// 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() | ||||
| @@ -161,7 +190,7 @@ func SyncChannelCache(frequency int) { | ||||
| } | ||||
|  | ||||
| func CacheGetRandomSatisfiedChannel(group string, model string) (*Channel, error) { | ||||
| 	if !common.RedisEnabled { | ||||
| 	if !common.MemoryCacheEnabled { | ||||
| 		return GetRandomSatisfiedChannel(group, model) | ||||
| 	} | ||||
| 	channelSyncLock.RLock() | ||||
| @@ -170,6 +199,17 @@ func CacheGetRandomSatisfiedChannel(group string, model string) (*Channel, error | ||||
| 	if len(channels) == 0 { | ||||
| 		return nil, errors.New("channel not found") | ||||
| 	} | ||||
| 	idx := rand.Intn(len(channels)) | ||||
| 	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 | ||||
| } | ||||
|   | ||||
| @@ -11,18 +11,19 @@ type Channel struct { | ||||
| 	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:''"` | ||||
| 	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) { | ||||
| @@ -37,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 ? or `key` = ?", keyword, 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 | ||||
| } | ||||
|  | ||||
| @@ -52,17 +57,6 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) { | ||||
| 	return &channel, err | ||||
| } | ||||
|  | ||||
| func GetRandomChannel() (*Channel, error) { | ||||
| 	channel := Channel{} | ||||
| 	var err error = nil | ||||
| 	if common.UsingSQLite { | ||||
| 		err = DB.Where("status = ? and `group` = ?", common.ChannelStatusEnabled, "default").Order("RANDOM()").Limit(1).First(&channel).Error | ||||
| 	} else { | ||||
| 		err = DB.Where("status = ? and `group` = ?", common.ChannelStatusEnabled, "default").Order("RAND()").Limit(1).First(&channel).Error | ||||
| 	} | ||||
| 	return &channel, err | ||||
| } | ||||
|  | ||||
| func BatchInsertChannels(channels []Channel) error { | ||||
| 	var err error | ||||
| 	err = DB.Create(&channels).Error | ||||
| @@ -78,6 +72,27 @@ func BatchInsertChannels(channels []Channel) error { | ||||
| 	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 | ||||
| @@ -141,8 +156,26 @@ func UpdateChannelStatusById(id int, status int) { | ||||
| } | ||||
|  | ||||
| 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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										40
									
								
								model/log.go
									
									
									
									
									
								
							
							
						
						
									
										40
									
								
								model/log.go
									
									
									
									
									
								
							| @@ -1,22 +1,25 @@ | ||||
| package model | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"gorm.io/gorm" | ||||
| 	"one-api/common" | ||||
| ) | ||||
|  | ||||
| type Log struct { | ||||
| 	Id               int    `json:"id"` | ||||
| 	UserId           int    `json:"user_id"` | ||||
| 	CreatedAt        int64  `json:"created_at" gorm:"bigint;index"` | ||||
| 	Type             int    `json:"type" gorm:"index"` | ||||
| 	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;default:''"` | ||||
| 	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;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 ( | ||||
| @@ -44,7 +47,8 @@ func RecordLog(userId int, logType int, content string) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func RecordConsumeLog(userId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int, content string) { | ||||
| 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 | ||||
| 	} | ||||
| @@ -59,14 +63,15 @@ func RecordConsumeLog(userId int, promptTokens int, completionTokens int, modelN | ||||
| 		TokenName:        tokenName, | ||||
| 		ModelName:        modelName, | ||||
| 		Quota:            quota, | ||||
| 		ChannelId:        channelId, | ||||
| 	} | ||||
| 	err := DB.Create(log).Error | ||||
| 	if err != nil { | ||||
| 		common.SysError("failed to record log: " + err.Error()) | ||||
| 		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) (logs []*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 | ||||
| @@ -88,6 +93,9 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName | ||||
| 	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 | ||||
| } | ||||
| @@ -125,8 +133,8 @@ func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) { | ||||
| 	return logs, err | ||||
| } | ||||
|  | ||||
| func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (quota int) { | ||||
| 	tx := DB.Table("logs").Select("sum(quota)") | ||||
| 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) | ||||
| 	} | ||||
| @@ -142,12 +150,15 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa | ||||
| 	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("sum(prompt_tokens) + sum(completion_tokens)") | ||||
| 	tx := DB.Table("logs").Select("ifnull(sum(prompt_tokens),0) + ifnull(sum(completion_tokens),0)") | ||||
| 	if username != "" { | ||||
| 		tx = tx.Where("username = ?", username) | ||||
| 	} | ||||
| @@ -166,3 +177,8 @@ func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelNa | ||||
| 	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 | ||||
| } | ||||
|   | ||||
| @@ -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,34 +36,54 @@ func createRootAccountIfNeed() error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func CountTable(tableName string) (num int64) { | ||||
| 	DB.Table(tableName).Count(&num) | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func InitDB() (err error) { | ||||
| 	var db *gorm.DB | ||||
| 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") | ||||
| 		db, err = gorm.Open(mysql.Open(os.Getenv("SQL_DSN")), &gorm.Config{ | ||||
| 			PrepareStmt: true, // precompile SQL | ||||
| 		}) | ||||
| 	} else { | ||||
| 		// Use SQLite | ||||
| 		common.SysLog("SQL_DSN not set, using SQLite as database") | ||||
| 		common.UsingSQLite = true | ||||
| 		db, err = gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{ | ||||
| 		return gorm.Open(mysql.Open(dsn), &gorm.Config{ | ||||
| 			PrepareStmt: true, // precompile SQL | ||||
| 		}) | ||||
| 	} | ||||
| 	common.SysLog("database connected") | ||||
| 	// 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) { | ||||
| 	db, err := chooseDB() | ||||
| 	if err == nil { | ||||
| 		if common.DebugEnabled { | ||||
| 			db = db.Debug() | ||||
| 		} | ||||
| 		DB = db | ||||
| 		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 | ||||
| 		} | ||||
| 		err := db.AutoMigrate(&Channel{}) | ||||
| 		common.SysLog("database migration started") | ||||
| 		err = db.AutoMigrate(&Channel{}) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|   | ||||
| @@ -50,8 +50,13 @@ func Redeem(key string, userId int) (quota int, err error) { | ||||
| 	} | ||||
| 	redemption := &Redemption{} | ||||
|  | ||||
| 	keyCol := "`key`" | ||||
| 	if common.UsingPostgreSQL { | ||||
| 		keyCol = `"key"` | ||||
| 	} | ||||
|  | ||||
| 	err = DB.Transaction(func(tx *gorm.DB) error { | ||||
| 		err := tx.Set("gorm:query_option", "FOR UPDATE").Where("`key` = ?", key).First(redemption).Error | ||||
| 		err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error | ||||
| 		if err != nil { | ||||
| 			return errors.New("无效的兑换码") | ||||
| 		} | ||||
|   | ||||
| @@ -39,32 +39,35 @@ func ValidateUserToken(key string) (token *Token, err 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("该令牌状态不可用") | ||||
| 		} | ||||
| 		if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() { | ||||
| 			token.Status = common.TokenStatusExpired | ||||
| 			err := token.SelectUpdate() | ||||
| 			if err != nil { | ||||
| 				common.SysError("failed to update token status" + 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("该令牌已过期") | ||||
| 		} | ||||
| 		if !token.UnlimitedQuota && token.RemainQuota <= 0 { | ||||
| 			token.Status = common.TokenStatusExhausted | ||||
| 			err := token.SelectUpdate() | ||||
| 			if err != nil { | ||||
| 				common.SysError("failed to update token status" + 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("该令牌额度已用尽") | ||||
| 		} | ||||
| 		go func() { | ||||
| 			token.AccessedTime = common.GetTimestamp() | ||||
| 			err := token.SelectUpdate() | ||||
| 			if err != nil { | ||||
| 				common.SysError("failed to update token" + err.Error()) | ||||
| 			} | ||||
| 		}() | ||||
| 		return token, nil | ||||
| 	} | ||||
| 	return nil, errors.New("无效的令牌") | ||||
| @@ -131,10 +134,19 @@ func IncreaseTokenQuota(id int, quota int) (err error) { | ||||
| 	if quota < 0 { | ||||
| 		return errors.New("quota 不能为负数!") | ||||
| 	} | ||||
| 	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), | ||||
| 			"remain_quota":  gorm.Expr("remain_quota + ?", quota), | ||||
| 			"used_quota":    gorm.Expr("used_quota - ?", quota), | ||||
| 			"accessed_time": common.GetTimestamp(), | ||||
| 		}, | ||||
| 	).Error | ||||
| 	return err | ||||
| @@ -144,10 +156,19 @@ func DecreaseTokenQuota(id int, quota int) (err error) { | ||||
| 	if quota < 0 { | ||||
| 		return errors.New("quota 不能为负数!") | ||||
| 	} | ||||
| 	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), | ||||
| 			"remain_quota":  gorm.Expr("remain_quota - ?", quota), | ||||
| 			"used_quota":    gorm.Expr("used_quota + ?", quota), | ||||
| 			"accessed_time": common.GetTimestamp(), | ||||
| 		}, | ||||
| 	).Error | ||||
| 	return err | ||||
|   | ||||
| @@ -226,17 +226,16 @@ func IsAdmin(userId int) bool { | ||||
| 	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) { | ||||
| @@ -267,7 +266,12 @@ func GetUserEmail(id int) (email string, err error) { | ||||
| } | ||||
|  | ||||
| func GetUserGroup(id int) (group string, err error) { | ||||
| 	err = DB.Model(&User{}).Where("id = ?", id).Select("`group`").Find(&group).Error | ||||
| 	groupCol := "`group`" | ||||
| 	if common.UsingPostgreSQL { | ||||
| 		groupCol = `"group"` | ||||
| 	} | ||||
|  | ||||
| 	err = DB.Model(&User{}).Where("id = ?", id).Select(groupCol).Find(&group).Error | ||||
| 	return group, err | ||||
| } | ||||
|  | ||||
| @@ -275,6 +279,14 @@ 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 | ||||
| } | ||||
| @@ -283,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 | ||||
| } | ||||
| @@ -293,10 +313,19 @@ func GetRootUserEmail() (email string) { | ||||
| } | ||||
|  | ||||
| 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 + ?", 1), | ||||
| 			"request_count": gorm.Expr("request_count + ?", count), | ||||
| 		}, | ||||
| 	).Error | ||||
| 	if err != nil { | ||||
| @@ -304,6 +333,24 @@ func UpdateUserUsedQuotaAndRequestCount(id int, quota int) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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 | ||||
|   | ||||
							
								
								
									
										77
									
								
								model/utils.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								model/utils.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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") | ||||
| } | ||||
| @@ -21,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) | ||||
| @@ -73,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") | ||||
| @@ -97,6 +99,7 @@ func SetApiRouter(router *gin.Engine) { | ||||
| 		} | ||||
| 		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) | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import ( | ||||
| ) | ||||
|  | ||||
| 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()) | ||||
| @@ -26,8 +27,8 @@ func SetRelayRouter(router *gin.Engine) { | ||||
| 		relayV1Router.POST("/images/variations", controller.RelayNotImplemented) | ||||
| 		relayV1Router.POST("/embeddings", controller.Relay) | ||||
| 		relayV1Router.POST("/engines/:model/embeddings", controller.Relay) | ||||
| 		relayV1Router.POST("/audio/transcriptions", controller.RelayNotImplemented) | ||||
| 		relayV1Router.POST("/audio/translations", controller.RelayNotImplemented) | ||||
| 		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) | ||||
|   | ||||
| @@ -18,7 +18,7 @@ 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") { | ||||
| 		if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") { | ||||
| 			controller.RelayNotFound(c) | ||||
| 			return | ||||
| 		} | ||||
|   | ||||
| @@ -283,7 +283,9 @@ function App() { | ||||
|           </Suspense> | ||||
|         } | ||||
|       /> | ||||
|       <Route path='*' element={NotFound} /> | ||||
|       <Route path='*' element={ | ||||
|           <NotFound /> | ||||
|       } /> | ||||
|     </Routes> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| 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'; | ||||
| @@ -24,7 +24,7 @@ function renderType(type) { | ||||
|     } | ||||
|     type2label[0] = { value: 0, text: '未知类型', color: 'grey' }; | ||||
|   } | ||||
|   return <Label basic color={type2label[type].color}>{type2label[type].text}</Label>; | ||||
|   return <Label basic color={type2label[type]?.color}>{type2label[type]?.text}</Label>; | ||||
| } | ||||
|  | ||||
| function renderBalance(type, balance) { | ||||
| @@ -55,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}`); | ||||
| @@ -96,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) { | ||||
| @@ -111,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) { | ||||
| @@ -135,9 +153,23 @@ const ChannelsTable = () => { | ||||
|         return <Label basic color='green'>已启用</Label>; | ||||
|       case 2: | ||||
|         return ( | ||||
|           <Label basic color='red'> | ||||
|             已禁用 | ||||
|           </Label> | ||||
|           <Popup | ||||
|             trigger={<Label basic color='red'> | ||||
|               已禁用 | ||||
|             </Label>} | ||||
|             content='本渠道被手动禁用' | ||||
|             basic | ||||
|           /> | ||||
|         ); | ||||
|       case 3: | ||||
|         return ( | ||||
|           <Popup | ||||
|             trigger={<Label basic color='yellow'> | ||||
|               已禁用 | ||||
|             </Label>} | ||||
|             content='本渠道被程序自动禁用' | ||||
|             basic | ||||
|           /> | ||||
|         ); | ||||
|       default: | ||||
|         return ( | ||||
| @@ -208,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; | ||||
| @@ -243,17 +286,15 @@ const ChannelsTable = () => { | ||||
|     if (channels.length === 0) return; | ||||
|     setLoading(true); | ||||
|     let sortedChannels = [...channels]; | ||||
|     if (typeof sortedChannels[0][key] === 'string') { | ||||
|       sortedChannels.sort((a, b) => { | ||||
|     sortedChannels.sort((a, b) => { | ||||
|       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]); | ||||
|       }); | ||||
|     } else { | ||||
|       sortedChannels.sort((a, b) => { | ||||
|         if (a[key] === b[key]) return 0; | ||||
|         if (a[key] > b[key]) return -1; | ||||
|         if (a[key] < b[key]) return 1; | ||||
|       }); | ||||
|     } | ||||
|       } | ||||
|     }); | ||||
|     if (sortedChannels[0].id === channels[0].id) { | ||||
|       sortedChannels.reverse(); | ||||
|     } | ||||
| @@ -261,6 +302,7 @@ const ChannelsTable = () => { | ||||
|     setLoading(false); | ||||
|   }; | ||||
|  | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Form onSubmit={searchChannels}> | ||||
| @@ -274,7 +316,19 @@ const ChannelsTable = () => { | ||||
|           onChange={handleKeywordChange} | ||||
|         /> | ||||
|       </Form> | ||||
|       { | ||||
|         showPrompt && ( | ||||
|           <Message onDismiss={() => { | ||||
|             setShowPrompt(false); | ||||
|             setPromptShown("channel-test"); | ||||
|           }}> | ||||
|             当前版本测试是通过按照 OpenAI API 格式使用 gpt-3.5-turbo | ||||
|             模型进行非流式请求实现的,因此测试报错并不一定代表通道不可用,该功能后续会修复。 | ||||
|  | ||||
|             另外,OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型,请点击余额进行刷新。 | ||||
|           </Message> | ||||
|         ) | ||||
|       } | ||||
|       <Table basic compact size='small'> | ||||
|         <Table.Header> | ||||
|           <Table.Row> | ||||
| @@ -334,6 +388,14 @@ const ChannelsTable = () => { | ||||
|             > | ||||
|               余额 | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
|               onClick={() => { | ||||
|                 sortChannel('priority'); | ||||
|               }} | ||||
|             > | ||||
|               优先级 | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell>操作</Table.HeaderCell> | ||||
|           </Table.Row> | ||||
|         </Table.Header> | ||||
| @@ -368,7 +430,23 @@ const ChannelsTable = () => { | ||||
|                       }} style={{ cursor: 'pointer' }}> | ||||
|                       {renderBalance(channel.type, channel.balance)} | ||||
|                     </span>} | ||||
|                       content="点击更新" | ||||
|                       content='点击更新' | ||||
|                       basic | ||||
|                     /> | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <Popup | ||||
|                       trigger={<Input type='number' defaultValue={channel.priority} onBlur={(event) => { | ||||
|                         manageChannel( | ||||
|                           channel.id, | ||||
|                           'priority', | ||||
|                           idx, | ||||
|                           event.target.value | ||||
|                         ); | ||||
|                       }}> | ||||
|                         <input style={{ maxWidth: '60px' }} /> | ||||
|                       </Input>} | ||||
|                       content='渠道选择优先级,越高越优先' | ||||
|                       basic | ||||
|                     /> | ||||
|                   </Table.Cell> | ||||
| @@ -440,7 +518,7 @@ const ChannelsTable = () => { | ||||
|  | ||||
|         <Table.Footer> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell colSpan='8'> | ||||
|             <Table.HeaderCell colSpan='9'> | ||||
|               <Button size='small' as={Link} to='/channel/add' loading={loading}> | ||||
|                 添加新的渠道 | ||||
|               </Button> | ||||
| @@ -449,6 +527,20 @@ const ChannelsTable = () => { | ||||
|               </Button> | ||||
|               <Button size='small' onClick={updateAllChannelsBalance} | ||||
|                       loading={loading || updatingBalance}>更新所有已启用通道余额</Button> | ||||
|               <Popup | ||||
|                 trigger={ | ||||
|                   <Button size='small' loading={loading}> | ||||
|                     删除禁用渠道 | ||||
|                   </Button> | ||||
|                 } | ||||
|                 on='click' | ||||
|                 flowing | ||||
|                 hoverable | ||||
|               > | ||||
|                 <Button size='small' loading={loading} negative onClick={deleteAllDisabledChannels}> | ||||
|                   确认删除 | ||||
|                 </Button> | ||||
|               </Popup> | ||||
|               <Pagination | ||||
|                 floated='right' | ||||
|                 activePage={activePage} | ||||
|   | ||||
| @@ -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 ( | ||||
|   | ||||
| @@ -2,7 +2,8 @@ import React, { useContext, useEffect, useState } from '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({ | ||||
| @@ -31,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); | ||||
|   }; | ||||
| @@ -73,8 +68,14 @@ const LoginForm = () => { | ||||
|       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); | ||||
|       } | ||||
| @@ -131,7 +132,7 @@ const LoginForm = () => { | ||||
|                 circular | ||||
|                 color='black' | ||||
|                 icon='github' | ||||
|                 onClick={onGitHubOAuthClicked} | ||||
|                 onClick={() => onGitHubOAuthClicked(status.github_client_id)} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <></> | ||||
|   | ||||
| @@ -43,6 +43,7 @@ function renderType(type) { | ||||
|  | ||||
| const LogsTable = () => { | ||||
|   const [logs, setLogs] = useState([]); | ||||
|   const [showStat, setShowStat] = useState(false); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [activePage, setActivePage] = useState(1); | ||||
|   const [searchKeyword, setSearchKeyword] = useState(''); | ||||
| @@ -55,9 +56,10 @@ const LogsTable = () => { | ||||
|     token_name: '', | ||||
|     model_name: '', | ||||
|     start_timestamp: timestamp2string(0), | ||||
|     end_timestamp: timestamp2string(now.getTime() / 1000 + 3600) | ||||
|     end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), | ||||
|     channel: '' | ||||
|   }); | ||||
|   const { username, token_name, model_name, start_timestamp, end_timestamp } = inputs; | ||||
|   const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs; | ||||
|  | ||||
|   const [stat, setStat] = useState({ | ||||
|     quota: 0, | ||||
| @@ -83,7 +85,7 @@ const LogsTable = () => { | ||||
|   const getLogStat = async () => { | ||||
|     let localStartTimestamp = Date.parse(start_timestamp) / 1000; | ||||
|     let localEndTimestamp = Date.parse(end_timestamp) / 1000; | ||||
|     let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`); | ||||
|     let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       setStat(data); | ||||
| @@ -92,12 +94,23 @@ const LogsTable = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleEyeClick = async () => { | ||||
|     if (!showStat) { | ||||
|       if (isAdminUser) { | ||||
|         await getLogStat(); | ||||
|       } else { | ||||
|         await getLogSelfStat(); | ||||
|       } | ||||
|     } | ||||
|     setShowStat(!showStat); | ||||
|   }; | ||||
|  | ||||
|   const loadLogs = async (startIdx) => { | ||||
|     let url = ''; | ||||
|     let localStartTimestamp = Date.parse(start_timestamp) / 1000; | ||||
|     let localEndTimestamp = Date.parse(end_timestamp) / 1000; | ||||
|     if (isAdminUser) { | ||||
|       url = `/api/log/?p=${startIdx}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; | ||||
|       url = `/api/log/?p=${startIdx}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`; | ||||
|     } else { | ||||
|       url = `/api/log/self/?p=${startIdx}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; | ||||
|     } | ||||
| @@ -129,13 +142,8 @@ const LogsTable = () => { | ||||
|  | ||||
|   const refresh = async () => { | ||||
|     setLoading(true); | ||||
|     setActivePage(1) | ||||
|     setActivePage(1); | ||||
|     await loadLogs(0); | ||||
|     if (isAdminUser) { | ||||
|       getLogStat().then(); | ||||
|     } else { | ||||
|       getLogSelfStat().then(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -169,7 +177,7 @@ const LogsTable = () => { | ||||
|     if (logs.length === 0) return; | ||||
|     setLoading(true); | ||||
|     let sortedLogs = [...logs]; | ||||
|     if (typeof sortedLogs[0][key] === 'string'){ | ||||
|     if (typeof sortedLogs[0][key] === 'string') { | ||||
|       sortedLogs.sort((a, b) => { | ||||
|         return ('' + a[key]).localeCompare(b[key]); | ||||
|       }); | ||||
| @@ -190,19 +198,17 @@ const LogsTable = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <Segment> | ||||
|         <Header as='h3'>使用明细(总消耗额度:{renderQuota(stat.quota)})</Header> | ||||
|         <Header as='h3'> | ||||
|           使用明细(总消耗额度: | ||||
|           {showStat && renderQuota(stat.quota)} | ||||
|           {!showStat && <span onClick={handleEyeClick} style={{ cursor: 'pointer', color: 'gray' }}>点击查看</span>} | ||||
|           ) | ||||
|         </Header> | ||||
|         <Form> | ||||
|           <Form.Group> | ||||
|             { | ||||
|               isAdminUser && ( | ||||
|                 <Form.Input fluid label={'用户名称'} width={2} value={username} | ||||
|                             placeholder={'可选值'} name='username' | ||||
|                             onChange={handleInputChange} /> | ||||
|               ) | ||||
|             } | ||||
|             <Form.Input fluid label={'令牌名称'} width={isAdminUser ? 2 : 3} value={token_name} | ||||
|             <Form.Input fluid label={'令牌名称'} width={3} value={token_name} | ||||
|                         placeholder={'可选值'} name='token_name' onChange={handleInputChange} /> | ||||
|             <Form.Input fluid label='模型名称' width={isAdminUser ? 2 : 3} value={model_name} placeholder='可选值' | ||||
|             <Form.Input fluid label='模型名称' width={3} value={model_name} placeholder='可选值' | ||||
|                         name='model_name' | ||||
|                         onChange={handleInputChange} /> | ||||
|             <Form.Input fluid label='起始时间' width={4} value={start_timestamp} type='datetime-local' | ||||
| @@ -213,6 +219,19 @@ const LogsTable = () => { | ||||
|                         onChange={handleInputChange} /> | ||||
|             <Form.Button fluid label='操作' width={2} onClick={refresh}>查询</Form.Button> | ||||
|           </Form.Group> | ||||
|           { | ||||
|             isAdminUser && <> | ||||
|               <Form.Group> | ||||
|                 <Form.Input fluid label={'渠道 ID'} width={3} value={channel} | ||||
|                             placeholder='可选值' name='channel' | ||||
|                             onChange={handleInputChange} /> | ||||
|                 <Form.Input fluid label={'用户名称'} width={3} value={username} | ||||
|                             placeholder={'可选值'} name='username' | ||||
|                             onChange={handleInputChange} /> | ||||
|  | ||||
|               </Form.Group> | ||||
|             </> | ||||
|           } | ||||
|         </Form> | ||||
|         <Table basic compact size='small'> | ||||
|           <Table.Header> | ||||
| @@ -226,6 +245,17 @@ const LogsTable = () => { | ||||
|               > | ||||
|                 时间 | ||||
|               </Table.HeaderCell> | ||||
|               { | ||||
|                 isAdminUser && <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('channel'); | ||||
|                   }} | ||||
|                   width={1} | ||||
|                 > | ||||
|                   渠道 | ||||
|                 </Table.HeaderCell> | ||||
|               } | ||||
|               { | ||||
|                 isAdminUser && <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
| @@ -287,16 +317,16 @@ const LogsTable = () => { | ||||
|                 onClick={() => { | ||||
|                   sortLog('quota'); | ||||
|                 }} | ||||
|                 width={2} | ||||
|                 width={1} | ||||
|               > | ||||
|                 消耗额度 | ||||
|                 额度 | ||||
|               </Table.HeaderCell> | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('content'); | ||||
|                 }} | ||||
|                 width={isAdminUser ? 4 : 5} | ||||
|                 width={isAdminUser ? 4 : 6} | ||||
|               > | ||||
|                 详情 | ||||
|               </Table.HeaderCell> | ||||
| @@ -312,8 +342,13 @@ const LogsTable = () => { | ||||
|               .map((log, idx) => { | ||||
|                 if (log.deleted) return <></>; | ||||
|                 return ( | ||||
|                   <Table.Row key={log.created_at}> | ||||
|                   <Table.Row key={log.id}> | ||||
|                     <Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell> | ||||
|                     { | ||||
|                       isAdminUser && ( | ||||
|                         <Table.Cell>{log.channel ? <Label basic>{log.channel}</Label> : ''}</Table.Cell> | ||||
|                       ) | ||||
|                     } | ||||
|                     { | ||||
|                       isAdminUser && ( | ||||
|                         <Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell> | ||||
| @@ -333,7 +368,7 @@ const LogsTable = () => { | ||||
|  | ||||
|           <Table.Footer> | ||||
|             <Table.Row> | ||||
|               <Table.HeaderCell colSpan={'9'}> | ||||
|               <Table.HeaderCell colSpan={'10'}> | ||||
|                 <Select | ||||
|                   placeholder='选择明细分类' | ||||
|                   options={LOG_OPTIONS} | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Divider, Form, Grid, Header } from 'semantic-ui-react'; | ||||
| import { API, showError, verifyJSON } from '../helpers'; | ||||
| import { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers'; | ||||
|  | ||||
| const OperationSetting = () => { | ||||
|   let now = new Date(); | ||||
|   let [inputs, setInputs] = useState({ | ||||
|     QuotaForNewUser: 0, | ||||
|     QuotaForInviter: 0, | ||||
| @@ -20,10 +21,11 @@ const OperationSetting = () => { | ||||
|     DisplayInCurrencyEnabled: '', | ||||
|     DisplayTokenStatEnabled: '', | ||||
|     ApproximateTokenEnabled: '', | ||||
|     RetryTimes: 0, | ||||
|     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/'); | ||||
| @@ -130,6 +132,17 @@ const OperationSetting = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   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 ( | ||||
|     <Grid columns={1}> | ||||
|       <Grid.Column> | ||||
| @@ -179,12 +192,6 @@ const OperationSetting = () => { | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.LogConsumeEnabled === 'true'} | ||||
|               label='启用额度消费日志记录' | ||||
|               name='LogConsumeEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.DisplayInCurrencyEnabled === 'true'} | ||||
|               label='以货币形式显示额度' | ||||
| @@ -208,6 +215,28 @@ const OperationSetting = () => { | ||||
|             submitConfig('general').then(); | ||||
|           }}>保存通用设置</Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             日志设置 | ||||
|           </Header> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.LogConsumeEnabled === 'true'} | ||||
|               label='启用额度消费日志记录' | ||||
|               name='LogConsumeEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths={4}> | ||||
|             <Form.Input label='目标时间' value={historyTimestamp} type='datetime-local' | ||||
|                         name='history_timestamp' | ||||
|                         onChange={(e, { name, value }) => { | ||||
|                           setHistoryTimestamp(value); | ||||
|                         }} /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|             deleteHistoryLogs().then(); | ||||
|           }}>清理历史日志</Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             监控设置 | ||||
|           </Header> | ||||
|   | ||||
| @@ -112,7 +112,7 @@ const OtherSetting = () => { | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='公告' | ||||
|               placeholder='在此输入新的公告内容' | ||||
|               placeholder='在此输入新的公告内容,支持 Markdown & HTML 代码' | ||||
|               value={inputs.Notice} | ||||
|               name='Notice' | ||||
|               onChange={handleInputChange} | ||||
|   | ||||
| @@ -4,6 +4,7 @@ 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); | ||||
| @@ -130,12 +131,6 @@ 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; | ||||
| @@ -249,7 +244,7 @@ const PersonalSetting = () => { | ||||
|       </Modal> | ||||
|       { | ||||
|         status.github_oauth && ( | ||||
|           <Button onClick={openGitHubOAuth}>绑定 GitHub 账号</Button> | ||||
|           <Button onClick={()=>{onGitHubOAuthClicked(status.github_client_id)}}>绑定 GitHub 账号</Button> | ||||
|         ) | ||||
|       } | ||||
|       <Button | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| 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'; | ||||
|  | ||||
| @@ -130,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(); | ||||
| @@ -240,15 +246,25 @@ const RedemptionsTable = () => { | ||||
|                       > | ||||
|                         复制 | ||||
|                       </Button> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         negative | ||||
|                         onClick={() => { | ||||
|                           manageRedemption(redemption.id, 'delete', idx); | ||||
|                         }} | ||||
|                       <Popup | ||||
|                         trigger={ | ||||
|                           <Button size='small' negative> | ||||
|                             删除 | ||||
|                           </Button> | ||||
|                         } | ||||
|                         on='click' | ||||
|                         flowing | ||||
|                         hoverable | ||||
|                       > | ||||
|                         删除 | ||||
|                       </Button> | ||||
|                         <Button | ||||
|                           negative | ||||
|                           onClick={() => { | ||||
|                             manageRedemption(redemption.id, 'delete', idx); | ||||
|                           }} | ||||
|                         > | ||||
|                           确认删除 | ||||
|                         </Button> | ||||
|                       </Popup> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         disabled={redemption.status === 3}  // used | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Divider, Form, Grid, Header, Input, Message } from 'semantic-ui-react'; | ||||
| import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react'; | ||||
| import { API, removeTrailingSlash, showError } from '../helpers'; | ||||
|  | ||||
| const SystemSetting = () => { | ||||
| @@ -33,6 +33,7 @@ const SystemSetting = () => { | ||||
|   let [loading, setLoading] = useState(false); | ||||
|   const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]); | ||||
|   const [restrictedDomainInput, setRestrictedDomainInput] = useState(''); | ||||
|   const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false); | ||||
|  | ||||
|   const getOptions = async () => { | ||||
|     const res = await API.get('/api/option/'); | ||||
| @@ -95,6 +96,11 @@ const SystemSetting = () => { | ||||
|   }; | ||||
|  | ||||
|   const handleInputChange = async (e, { name, value }) => { | ||||
|     if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') { | ||||
|       // block disabling password login | ||||
|       setShowPasswordWarningModal(true); | ||||
|       return; | ||||
|     } | ||||
|     if ( | ||||
|       name === 'Notice' || | ||||
|       name.startsWith('SMTP') || | ||||
| @@ -243,6 +249,32 @@ const SystemSetting = () => { | ||||
|               name='PasswordLoginEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             { | ||||
|               showPasswordWarningModal && | ||||
|               <Modal | ||||
|                 open={showPasswordWarningModal} | ||||
|                 onClose={() => setShowPasswordWarningModal(false)} | ||||
|                 size={'tiny'} | ||||
|                 style={{ maxWidth: '450px' }} | ||||
|               > | ||||
|                 <Modal.Header>警告</Modal.Header> | ||||
|                 <Modal.Content> | ||||
|                   <p>取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?</p> | ||||
|                 </Modal.Content> | ||||
|                 <Modal.Actions> | ||||
|                   <Button onClick={() => setShowPasswordWarningModal(false)}>取消</Button> | ||||
|                   <Button | ||||
|                     color='yellow' | ||||
|                     onClick={async () => { | ||||
|                       setShowPasswordWarningModal(false); | ||||
|                       await updateOption('PasswordLoginEnabled', 'false'); | ||||
|                     }} | ||||
|                   > | ||||
|                     确定 | ||||
|                   </Button> | ||||
|                 </Modal.Actions> | ||||
|               </Modal> | ||||
|             } | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.PasswordRegisterEnabled === 'true'} | ||||
|               label='允许通过密码进行注册' | ||||
|   | ||||
| @@ -96,7 +96,7 @@ const TokensTable = () => { | ||||
|     let nextUrl; | ||||
|    | ||||
|     if (nextLink) { | ||||
|       nextUrl = nextLink + `/#/?settings={"key":"sk-${key}"}`; | ||||
|       nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|     } else { | ||||
|       nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|     } | ||||
| @@ -138,7 +138,7 @@ const TokensTable = () => { | ||||
|     let defaultUrl; | ||||
|    | ||||
|     if (chatLink) { | ||||
|       defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}"}`; | ||||
|       defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|     } else { | ||||
|       defaultUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; | ||||
|     } | ||||
| @@ -228,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(); | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
							
								
								
									
										20
									
								
								web/src/components/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/src/components/utils.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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` | ||||
|   ); | ||||
| } | ||||
| @@ -7,7 +7,12 @@ export const CHANNEL_OPTIONS = [ | ||||
|   { 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' }, | ||||
|   | ||||
| @@ -1,6 +1,11 @@ | ||||
| import { toast } from 'react-toastify'; | ||||
| import { toastConstants } from '../constants'; | ||||
| import React from 'react'; | ||||
|  | ||||
| const HTMLToastContent = ({ htmlContent }) => { | ||||
|   return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />; | ||||
| }; | ||||
| 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(<HTMLToastContent htmlContent={message} />, showNoticeOptions); | ||||
|   } else { | ||||
|     toast.info(message, showNoticeOptions); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function openPage(url) { | ||||
| @@ -177,4 +186,14 @@ export const verifyJSON = (str) => { | ||||
|     return false; | ||||
|   } | ||||
|   return true; | ||||
| }; | ||||
| }; | ||||
|  | ||||
| export function shouldShowPrompt(id) { | ||||
|   let prompt = localStorage.getItem(`prompt-${id}`); | ||||
|   return !prompt; | ||||
|  | ||||
| } | ||||
|  | ||||
| export function setPromptShown(id) { | ||||
|   localStorage.setItem(`prompt-${id}`, 'true'); | ||||
| } | ||||
| @@ -1,6 +1,6 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Header, Input, Message, Segment } from 'semantic-ui-react'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| import { useNavigate, useParams } from 'react-router-dom'; | ||||
| import { API, showError, showInfo, showSuccess, verifyJSON } from '../../helpers'; | ||||
| import { CHANNEL_OPTIONS } from '../../constants'; | ||||
|  | ||||
| @@ -10,11 +10,32 @@ const MODEL_MAPPING_EXAMPLE = { | ||||
|   'gpt-4-32k-0314': 'gpt-4-32k' | ||||
| }; | ||||
|  | ||||
| function type2secretPrompt(type) { | ||||
|   // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥') | ||||
|   switch (type) { | ||||
|     case 15: | ||||
|       return '按照如下格式输入:APIKey|SecretKey'; | ||||
|     case 18: | ||||
|       return '按照如下格式输入:APPID|APISecret|APIKey'; | ||||
|     case 22: | ||||
|       return '按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041'; | ||||
|     case 23: | ||||
|       return '按照如下格式输入:AppId|SecretId|SecretKey'; | ||||
|     default: | ||||
|       return '请输入渠道对应的鉴权密钥'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const EditChannel = () => { | ||||
|   const params = useParams(); | ||||
|   const navigate = useNavigate(); | ||||
|   const channelId = params.id; | ||||
|   const isEdit = channelId !== undefined; | ||||
|   const [loading, setLoading] = useState(isEdit); | ||||
|   const handleCancel = () => { | ||||
|     navigate('/channel'); | ||||
|   }; | ||||
|  | ||||
|   const originInputs = { | ||||
|     name: '', | ||||
|     type: 1, | ||||
| @@ -45,17 +66,23 @@ const EditChannel = () => { | ||||
|           localModels = ['PaLM-2']; | ||||
|           break; | ||||
|         case 15: | ||||
|           localModels = ['ERNIE-Bot', 'ERNIE-Bot-turbo', 'Embedding-V1']; | ||||
|           localModels = ['ERNIE-Bot', 'ERNIE-Bot-turbo', 'ERNIE-Bot-4', 'Embedding-V1']; | ||||
|           break; | ||||
|         case 17: | ||||
|           localModels = ['qwen-v1', 'qwen-plus-v1']; | ||||
|           localModels = ['qwen-turbo', 'qwen-plus', 'text-embedding-v1']; | ||||
|           break; | ||||
|         case 16: | ||||
|           localModels = ['chatglm_pro', 'chatglm_std', 'chatglm_lite']; | ||||
|           localModels = ['chatglm_turbo', 'chatglm_pro', 'chatglm_std', 'chatglm_lite']; | ||||
|           break; | ||||
|         case 18: | ||||
|           localModels = ['SparkDesk']; | ||||
|           break; | ||||
|         case 19: | ||||
|           localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1']; | ||||
|           break; | ||||
|         case 23: | ||||
|           localModels = ['hunyuan']; | ||||
|           break; | ||||
|       } | ||||
|       setInputs((inputs) => ({ ...inputs, models: localModels })); | ||||
|     } | ||||
| @@ -152,14 +179,14 @@ const EditChannel = () => { | ||||
|       return; | ||||
|     } | ||||
|     let localInputs = inputs; | ||||
|     if (localInputs.base_url.endsWith('/')) { | ||||
|     if (localInputs.base_url && localInputs.base_url.endsWith('/')) { | ||||
|       localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1); | ||||
|     } | ||||
|     if (localInputs.type === 3 && localInputs.other === '') { | ||||
|       localInputs.other = '2023-06-01-preview'; | ||||
|     } | ||||
|     if (localInputs.model_mapping === '') { | ||||
|       localInputs.model_mapping = '{}'; | ||||
|     if (localInputs.type === 18 && localInputs.other === '') { | ||||
|       localInputs.other = 'v2.1'; | ||||
|     } | ||||
|     let res; | ||||
|     localInputs.models = localInputs.models.join(','); | ||||
| @@ -182,6 +209,24 @@ const EditChannel = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const addCustomModel = () => { | ||||
|     if (customModel.trim() === '') return; | ||||
|     if (inputs.models.includes(customModel)) return; | ||||
|     let localModels = [...inputs.models]; | ||||
|     localModels.push(customModel); | ||||
|     let localModelOptions = []; | ||||
|     localModelOptions.push({ | ||||
|       key: customModel, | ||||
|       text: customModel, | ||||
|       value: customModel | ||||
|     }); | ||||
|     setModelOptions(modelOptions => { | ||||
|       return [...modelOptions, ...localModelOptions]; | ||||
|     }); | ||||
|     setCustomModel(''); | ||||
|     handleInputChange(null, { name: 'models', value: localModels }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Segment loading={loading}> | ||||
| @@ -270,6 +315,34 @@ const EditChannel = () => { | ||||
|               options={groupOptions} | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           { | ||||
|             inputs.type === 18 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='模型版本' | ||||
|                   name='other' | ||||
|                   placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'} | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.other} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 21 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='知识库 ID' | ||||
|                   name='other' | ||||
|                   placeholder={'请输入知识库 ID,例如:123456'} | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.other} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           <Form.Field> | ||||
|             <Form.Dropdown | ||||
|               label='模型' | ||||
| @@ -297,29 +370,19 @@ const EditChannel = () => { | ||||
|             }}>清除所有模型</Button> | ||||
|             <Input | ||||
|               action={ | ||||
|                 <Button type={'button'} onClick={() => { | ||||
|                   if (customModel.trim() === '') return; | ||||
|                   if (inputs.models.includes(customModel)) return; | ||||
|                   let localModels = [...inputs.models]; | ||||
|                   localModels.push(customModel); | ||||
|                   let localModelOptions = []; | ||||
|                   localModelOptions.push({ | ||||
|                     key: customModel, | ||||
|                     text: customModel, | ||||
|                     value: customModel | ||||
|                   }); | ||||
|                   setModelOptions(modelOptions => { | ||||
|                     return [...modelOptions, ...localModelOptions]; | ||||
|                   }); | ||||
|                   setCustomModel(''); | ||||
|                   handleInputChange(null, { name: 'models', value: localModels }); | ||||
|                 }}>填入</Button> | ||||
|                 <Button type={'button'} onClick={addCustomModel}>填入</Button> | ||||
|               } | ||||
|               placeholder='输入自定义模型名称' | ||||
|               value={customModel} | ||||
|               onChange={(e, { value }) => { | ||||
|                 setCustomModel(value); | ||||
|               }} | ||||
|               onKeyDown={(e) => { | ||||
|                 if (e.key === 'Enter') { | ||||
|                   addCustomModel(); | ||||
|                   e.preventDefault(); | ||||
|                 } | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <Form.Field> | ||||
| @@ -350,7 +413,7 @@ const EditChannel = () => { | ||||
|                 label='密钥' | ||||
|                 name='key' | ||||
|                 required | ||||
|                 placeholder={inputs.type === 15 ? '请输入 access token,当前版本暂不支持自动刷新,请每 30 天更新一次' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')} | ||||
|                 placeholder={type2secretPrompt(inputs.type)} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.key} | ||||
|                 autoComplete='new-password' | ||||
| @@ -368,7 +431,7 @@ const EditChannel = () => { | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type !== 3 && inputs.type !== 8 && ( | ||||
|             inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='代理' | ||||
| @@ -381,6 +444,21 @@ const EditChannel = () => { | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           { | ||||
|             inputs.type === 22 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Input | ||||
|                   label='私有部署地址' | ||||
|                   name='base_url' | ||||
|                   placeholder={'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'} | ||||
|                   onChange={handleInputChange} | ||||
|                   value={inputs.base_url} | ||||
|                   autoComplete='new-password' | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             ) | ||||
|           } | ||||
|           <Button onClick={handleCancel}>取消</Button> | ||||
|           <Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>提交</Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|   | ||||
| @@ -14,10 +14,11 @@ const Home = () => { | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       let oldNotice = localStorage.getItem('notice'); | ||||
|       if (data !== oldNotice && data !== '') { | ||||
|         showNotice(data); | ||||
|         localStorage.setItem('notice', data); | ||||
|       } | ||||
|         if (data !== oldNotice && data !== '') { | ||||
|             const htmlNotice = marked(data); | ||||
|             showNotice(htmlNotice, true); | ||||
|             localStorage.setItem('notice', data); | ||||
|         } | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
| @@ -64,7 +65,7 @@ const Home = () => { | ||||
|                     <Card.Meta>系统信息总览</Card.Meta> | ||||
|                     <Card.Description> | ||||
|                       <p>名称:{statusState?.status?.system_name}</p> | ||||
|                       <p>版本:{statusState?.status?.version}</p> | ||||
|                       <p>版本:{statusState?.status?.version ? statusState?.status?.version : "unknown"}</p> | ||||
|                       <p> | ||||
|                         源码: | ||||
|                         <a | ||||
|   | ||||
| @@ -1,19 +1,12 @@ | ||||
| import React from 'react'; | ||||
| import { Segment, Header } from 'semantic-ui-react'; | ||||
| import { Message } from 'semantic-ui-react'; | ||||
|  | ||||
| const NotFound = () => ( | ||||
|   <> | ||||
|     <Header | ||||
|       block | ||||
|       as="h4" | ||||
|       content="404" | ||||
|       attached="top" | ||||
|       icon="info" | ||||
|       className="small-icon" | ||||
|     /> | ||||
|     <Segment attached="bottom"> | ||||
|       未找到所请求的页面 | ||||
|     </Segment> | ||||
|     <Message negative> | ||||
|       <Message.Header>页面不存在</Message.Header> | ||||
|       <p>请检查你的浏览器地址是否正确</p> | ||||
|     </Message> | ||||
|   </> | ||||
| ); | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Form, Header, Segment } from 'semantic-ui-react'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| import { useParams, useNavigate } from 'react-router-dom'; | ||||
| import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers'; | ||||
| import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render'; | ||||
|  | ||||
| const EditRedemption = () => { | ||||
|   const params = useParams(); | ||||
|   const navigate = useNavigate(); | ||||
|   const redemptionId = params.id; | ||||
|   const isEdit = redemptionId !== undefined; | ||||
|   const [loading, setLoading] = useState(isEdit); | ||||
| @@ -17,6 +18,10 @@ const EditRedemption = () => { | ||||
|   const [inputs, setInputs] = useState(originInputs); | ||||
|   const { name, quota, count } = inputs; | ||||
|  | ||||
|   const handleCancel = () => { | ||||
|     navigate('/redemption'); | ||||
|   }; | ||||
|    | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
|   }; | ||||
| @@ -113,6 +118,7 @@ const EditRedemption = () => { | ||||
|             </> | ||||
|           } | ||||
|           <Button positive onClick={submit}>提交</Button> | ||||
|           <Button onClick={handleCancel}>取消</Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|     </> | ||||
|   | ||||
| @@ -102,7 +102,7 @@ const EditUser = () => { | ||||
|               label='密码' | ||||
|               name='password' | ||||
|               type={'password'} | ||||
|               placeholder={'请输入新的密码'} | ||||
|               placeholder={'请输入新的密码,最短 8 位'} | ||||
|               onChange={handleInputChange} | ||||
|               value={password} | ||||
|               autoComplete='new-password' | ||||
|   | ||||
		Reference in New Issue
	
	Block a user