mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-31 10:23:01 +00:00
Compare commits
34 Commits
v0.9.2.0
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c456f39c | ||
|
|
7437b671ef | ||
|
|
55d19df029 | ||
|
|
731e9f4ca9 | ||
|
|
cc6fcebda1 | ||
|
|
72a12e3747 | ||
|
|
0adfcf9d27 | ||
|
|
51d71a6e1a | ||
|
|
c9abe1d769 | ||
|
|
a8bfa7ad29 | ||
|
|
f24feed775 | ||
|
|
6b75bc0016 | ||
|
|
937d931442 | ||
|
|
5c6e6032ef | ||
|
|
d5e01a3eab | ||
|
|
69e1542fc9 | ||
|
|
3199e2e8cd | ||
|
|
66d0764fc1 | ||
|
|
01bcbf09c6 | ||
|
|
0682a15971 | ||
|
|
19bbb7d7c7 | ||
|
|
ace855ed36 | ||
|
|
36ed41ad7a | ||
|
|
df19a8de5d | ||
|
|
0074085b13 | ||
|
|
f473d20a09 | ||
|
|
9061411ec7 | ||
|
|
6ee01d75a6 | ||
|
|
b0b275b236 | ||
|
|
81a66be721 | ||
|
|
0735b0c604 | ||
|
|
c4e0fc1837 | ||
|
|
39a868faea | ||
|
|
f354e5de23 |
26
.github/ISSUE_TEMPLATE/bug_report_en.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/bug_report_en.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Describe the issue you encountered with clear and detailed language
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Routine Checks**
|
||||
|
||||
[//]: # (Remove the space in the box and fill with an x)
|
||||
+ [ ] I have confirmed there are no similar issues currently
|
||||
+ [ ] I have confirmed I have upgraded to the latest version
|
||||
+ [ ] I have thoroughly read the project README, especially the FAQ section
|
||||
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
|
||||
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
|
||||
|
||||
**Issue Description**
|
||||
|
||||
**Steps to Reproduce**
|
||||
|
||||
**Expected Result**
|
||||
|
||||
**Related Screenshots**
|
||||
If none, please delete this section.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request_en.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/feature_request_en.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Describe the new feature you would like to add with clear and detailed language
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Routine Checks**
|
||||
|
||||
[//]: # (Remove the space in the box and fill with an x)
|
||||
+ [ ] I have confirmed there are no similar issues currently
|
||||
+ [ ] I have confirmed I have upgraded to the latest version
|
||||
+ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs
|
||||
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
|
||||
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
|
||||
|
||||
**Feature Description**
|
||||
|
||||
**Use Case**
|
||||
|
||||
@@ -13,7 +13,3 @@
|
||||
### PR 描述
|
||||
|
||||
**请在下方详细描述您的 PR,包括目的、实现细节等。**
|
||||
|
||||
### **重要提示**
|
||||
|
||||
**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。**
|
||||
|
||||
23
README.en.md
23
README.en.md
@@ -1,6 +1,10 @@
|
||||
<p align="right">
|
||||
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a>
|
||||
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
@@ -75,7 +79,7 @@ New API offers a wide range of features, please refer to [Features Introduction]
|
||||
|
||||
1. 🎨 Brand new UI interface
|
||||
2. 🌍 Multi-language support
|
||||
3. 💰 Online recharge functionality (YiPay)
|
||||
3. 💰 Online recharge functionality, currently supports EPay and Stripe
|
||||
4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
5. 🔄 Compatible with the original One API database
|
||||
6. 💵 Support for pay-per-use model pricing
|
||||
@@ -96,7 +100,11 @@ New API offers a wide range of features, please refer to [Features Introduction]
|
||||
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 Thinking-to-content functionality
|
||||
17. 🔄 Model rate limiting for users
|
||||
18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
||||
18. 🔄 Request format conversion functionality, supporting the following three format conversions:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
||||
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
|
||||
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
|
||||
3. Supported channels:
|
||||
@@ -115,7 +123,9 @@ This version supports multiple models, please refer to [API Documentation-Relay
|
||||
4. Custom channels, supporting full call address input
|
||||
5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||
7. Dify, currently only supports chatflow
|
||||
7. Google Gemini format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
8. Dify, currently only supports chatflow
|
||||
9. For more interfaces, please refer to [API Documentation](https://docs.newapi.pro/api)
|
||||
|
||||
## Environment Variable Configuration
|
||||
|
||||
@@ -192,17 +202,16 @@ For detailed API documentation, please refer to [API Documentation](https://docs
|
||||
- [Image API](https://docs.newapi.pro/api/openai-image)
|
||||
- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [Realtime API](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
## Related Projects
|
||||
- [One API](https://github.com/songquanpeng/one-api): Original project
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
|
||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
|
||||
|
||||
Other projects based on New API:
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
|
||||
- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API
|
||||
|
||||
## Help and Support
|
||||
|
||||
|
||||
23
README.fr.md
23
README.fr.md
@@ -1,6 +1,10 @@
|
||||
<p align="right">
|
||||
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong>
|
||||
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong> | <a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **MT (Traduction Automatique)**: Ce document est traduit automatiquement. Pour les informations les plus précises, veuillez vous référer à la [version chinoise](./README.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
@@ -75,7 +79,7 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à
|
||||
|
||||
1. 🎨 Nouvelle interface utilisateur
|
||||
2. 🌍 Prise en charge multilingue
|
||||
3. 💰 Fonctionnalité de recharge en ligne (YiPay)
|
||||
3. 💰 Fonctionnalité de recharge en ligne, prend actuellement en charge EPay et Stripe
|
||||
4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
5. 🔄 Compatible avec la base de données originale de One API
|
||||
6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation
|
||||
@@ -96,7 +100,11 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à
|
||||
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 Fonctionnalité de la pensée au contenu
|
||||
17. 🔄 Limitation du débit du modèle pour les utilisateurs
|
||||
18. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||
18. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
|
||||
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
|
||||
3. Canaux pris en charge :
|
||||
@@ -115,7 +123,9 @@ Cette version prend en charge plusieurs modèles, veuillez vous référer à [Do
|
||||
4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel
|
||||
5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
7. Dify, ne prend actuellement en charge que chatflow
|
||||
7. Format Google Gemini, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
8. Dify, ne prend actuellement en charge que chatflow
|
||||
9. Pour plus d'interfaces, veuillez vous référer à la [Documentation de l'API](https://docs.newapi.pro/api)
|
||||
|
||||
## Configuration des variables d'environnement
|
||||
|
||||
@@ -192,17 +202,16 @@ Pour une documentation détaillée de l'API, veuillez vous référer à [Documen
|
||||
- [API d'image](https://docs.newapi.pro/api/openai-image)
|
||||
- [API de rerank](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [API en temps réel](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [API de discussion Claude (messages)](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
## Projets connexes
|
||||
- [One API](https://github.com/songquanpeng/one-api) : Projet original
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
|
||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio) : Solution B/C unique d'IA de nouvelle génération
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
|
||||
|
||||
Autres projets basés sur New API :
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
|
||||
- [VoAPI](https://github.com/VoAPI/VoAPI) : Version embellie du frontend basée sur New API
|
||||
|
||||
## Aide et support
|
||||
|
||||
|
||||
224
README.ja.md
Normal file
224
README.ja.md
Normal file
@@ -0,0 +1,224 @@
|
||||
<p align="right">
|
||||
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <strong>日本語</strong>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **MT(機械翻訳)**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
# New API
|
||||
|
||||
🍥次世代大規模モデルゲートウェイとAI資産管理システム
|
||||
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
|
||||
</a>
|
||||
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
|
||||
</a>
|
||||
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
|
||||
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/CalciumIon/new-api">
|
||||
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
|
||||
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## 📝 プロジェクト説明
|
||||
|
||||
> [!NOTE]
|
||||
> 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)をベースに二次開発されたオープンソースプロジェクトです
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。
|
||||
> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。
|
||||
> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
|
||||
|
||||
<h2>🤝 信頼できるパートナー</h2>
|
||||
<p id="premium-sponsors"> </p>
|
||||
<p align="center"><strong>順不同</strong></p>
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||
/></a>
|
||||
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||
src="./docs/images/pku.png" alt="北京大学" height="120"
|
||||
/></a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||
src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="120"
|
||||
/></a>
|
||||
<a href="https://www.aliyun.com/" target=_blank><img
|
||||
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
||||
/></a>
|
||||
<a href="https://io.net/" target=_blank><img
|
||||
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||
/></a>
|
||||
</p>
|
||||
<p> </p>
|
||||
|
||||
## 📚 ドキュメント
|
||||
|
||||
詳細なドキュメントは公式Wikiをご覧ください:[https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||
|
||||
AIが生成したDeepWikiにもアクセスできます:
|
||||
[](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
## ✨ 主な機能
|
||||
|
||||
New APIは豊富な機能を提供しています。詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください:
|
||||
|
||||
1. 🎨 全く新しいUIインターフェース
|
||||
2. 🌍 多言語サポート
|
||||
3. 💰 オンラインチャージ機能をサポート、現在EPayとStripeをサポート
|
||||
4. 🔍 キーによる使用量クォータの照会をサポート([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と連携)
|
||||
5. 🔄 オリジナルのOne APIデータベースと互換性あり
|
||||
6. 💵 モデルの従量課金をサポート
|
||||
7. ⚖️ チャネルの重み付けランダムをサポート
|
||||
8. 📈 データダッシュボード(コンソール)
|
||||
9. 🔒 トークングループ化、モデル制限
|
||||
10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC)
|
||||
11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Claude Messages形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. /chat2linkルートを使用してチャット画面に入ることをサポート
|
||||
15. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート:
|
||||
1. OpenAI oシリーズモデル
|
||||
- `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`)
|
||||
- `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`)
|
||||
- `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`)
|
||||
2. Claude思考モデル
|
||||
- `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 思考からコンテンツへの機能
|
||||
17. 🔄 ユーザーに対するモデルレート制限機能
|
||||
18. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
|
||||
1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定
|
||||
2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金
|
||||
3. サポートされているチャネル:
|
||||
- [x] OpenAI
|
||||
- [x] Azure
|
||||
- [x] DeepSeek
|
||||
- [x] Claude
|
||||
|
||||
## モデルサポート
|
||||
|
||||
このバージョンは複数のモデルをサポートしています。詳細は[APIドキュメント-中継インターフェース](https://docs.newapi.pro/api)を参照してください:
|
||||
|
||||
1. サードパーティモデル **gpts**(gpt-4-gizmo-*)
|
||||
2. サードパーティチャネル[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image)
|
||||
3. サードパーティチャネル[Suno API](https://github.com/Suno-API/Suno-API)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/suno-music)
|
||||
4. カスタムチャネル、完全な呼び出しアドレスの入力をサポート
|
||||
5. Rerankモデル([Cohere](https://cohere.ai/)と[Jina](https://jina.ai/))、[APIドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
6. Claude Messages形式、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||
7. Google Gemini形式、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
8. Dify、現在はchatflowのみをサポート
|
||||
9. その他のインターフェースについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください
|
||||
|
||||
## 環境変数設定
|
||||
|
||||
詳細な設定説明については[インストールガイド-環境変数設定](https://docs.newapi.pro/installation/environment-variables)を参照してください:
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN`:新規登録ユーザーに初期トークンを生成するかどうか、デフォルトは`false`
|
||||
- `STREAMING_TIMEOUT`:ストリーミング応答のタイムアウト時間、デフォルトは300秒
|
||||
- `DIFY_DEBUG`:Difyチャネルがワークフローとノード情報を出力するかどうか、デフォルトは`true`
|
||||
- `GET_MEDIA_TOKEN`:画像トークンを統計するかどうか、デフォルトは`true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM`:非ストリーミングの場合に画像トークンを統計するかどうか、デフォルトは`true`
|
||||
- `UPDATE_TASK`:非同期タスク(Midjourney、Suno)を更新するかどうか、デフォルトは`true`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM`:Geminiモデルの最大画像数、デフォルトは`16`
|
||||
- `MAX_FILE_DOWNLOAD_MB`: 最大ファイルダウンロードサイズ、単位MB、デフォルトは`20`
|
||||
- `CRYPTO_SECRET`:暗号化キー、Redisデータベースの内容を暗号化するために使用
|
||||
- `AZURE_DEFAULT_API_VERSION`:Azureチャネルのデフォルトのバージョン、デフォルトは`2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:メールなどの通知制限の継続時間、デフォルトは`10`分
|
||||
- `NOTIFY_LIMIT_COUNT`:指定された継続時間内のユーザー通知の最大数、デフォルトは`2`
|
||||
- `ERROR_LOG_ENABLED=true`: エラーログを記録して表示するかどうか、デフォルトは`false`
|
||||
|
||||
## デプロイ
|
||||
|
||||
詳細なデプロイガイドについては[インストールガイド-デプロイ方法](https://docs.newapi.pro/installation)を参照してください:
|
||||
|
||||
> [!TIP]
|
||||
> 最新のDockerイメージ:`calciumion/new-api:latest`
|
||||
|
||||
### マルチマシンデプロイの注意事項
|
||||
- 環境変数`SESSION_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
|
||||
- Redisを共有する場合、`CRYPTO_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にRedisの内容を取得できません
|
||||
|
||||
### デプロイ要件
|
||||
- ローカルデータベース(デフォルト):SQLite(Dockerデプロイの場合は`/data`ディレクトリをマウントする必要があります)
|
||||
- リモートデータベース:MySQLバージョン >= 5.7.8、PgSQLバージョン >= 9.6
|
||||
|
||||
### デプロイ方法
|
||||
|
||||
#### 宝塔パネルのDocker機能を使用してデプロイ
|
||||
宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を見つけてインストールします。
|
||||
[画像付きチュートリアル](./docs/BT.md)
|
||||
|
||||
#### Docker Composeを使用してデプロイ(推奨)
|
||||
```shell
|
||||
# プロジェクトをダウンロード
|
||||
git clone https://github.com/Calcium-Ion/new-api.git
|
||||
cd new-api
|
||||
# 必要に応じてdocker-compose.ymlを編集
|
||||
# 起動
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Dockerイメージを直接使用
|
||||
```shell
|
||||
# SQLiteを使用
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
|
||||
# MySQLを使用
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
```
|
||||
|
||||
## チャネルリトライとキャッシュ
|
||||
チャネルリトライ機能はすでに実装されており、`設定->運営設定->一般設定->失敗リトライ回数`でリトライ回数を設定できます。**キャッシュ機能を有効にすることを推奨します**。
|
||||
|
||||
### キャッシュ設定方法
|
||||
1. `REDIS_CONN_STRING`:Redisをキャッシュとして設定
|
||||
2. `MEMORY_CACHE_ENABLED`:メモリキャッシュを有効にする(Redisを設定した場合は手動設定不要)
|
||||
|
||||
## APIドキュメント
|
||||
|
||||
詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください:
|
||||
|
||||
- [チャットインターフェース(Chat)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claudeチャットインターフェース](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Geminiチャットインターフェース](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
## 関連プロジェクト
|
||||
- [One API](https://github.com/songquanpeng/one-api):オリジナルプロジェクト
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourneyインターフェースサポート
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):キーを使用して使用量クォータを照会
|
||||
|
||||
New APIベースのその他のプロジェクト:
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能最適化版
|
||||
|
||||
## ヘルプサポート
|
||||
|
||||
問題がある場合は、[ヘルプサポート](https://docs.newapi.pro/support)を参照してください:
|
||||
- [コミュニティ交流](https://docs.newapi.pro/support/community-interaction)
|
||||
- [問題のフィードバック](https://docs.newapi.pro/support/feedback-issues)
|
||||
- [よくある質問](https://docs.newapi.pro/support/faq)
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||
|
||||
20
README.md
20
README.md
@@ -1,5 +1,5 @@
|
||||
<p align="right">
|
||||
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a>
|
||||
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
@@ -75,7 +75,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
|
||||
1. 🎨 全新的UI界面
|
||||
2. 🌍 多语言支持
|
||||
3. 💰 支持在线充值功能(易支付)
|
||||
3. 💰 支持在线充值功能,当前支持易支付和Stripe
|
||||
4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
5. 🔄 兼容原版One API的数据库
|
||||
6. 💵 支持模型按次数收费
|
||||
@@ -119,7 +119,9 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
4. 自定义渠道,支持填入完整调用地址
|
||||
5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||
7. Dify,当前仅支持chatflow
|
||||
7. Google Gemini格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
8. Dify,当前仅支持chatflow
|
||||
9. 更多接口请参考[接口文档](https://docs.newapi.pro/api)
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
@@ -128,16 +130,14 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
|
||||
- `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒
|
||||
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
|
||||
- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
|
||||
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true`
|
||||
- `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true`
|
||||
- `COHERE_SAFETY_SETTING`:Cohere模型安全设置,可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16`
|
||||
- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20`
|
||||
- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容
|
||||
- `CRYPTO_SECRET`:加密密钥,用于加密Redis数据库内容
|
||||
- `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟
|
||||
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
|
||||
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
|
||||
|
||||
@@ -182,7 +182,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
```
|
||||
|
||||
## 渠道重试与缓存
|
||||
渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。
|
||||
渠道重试功能已经实现,可以在`设置->运营设置->通用设置->失败重试次数`设置重试次数,**建议开启缓存**功能。
|
||||
|
||||
### 缓存设置方法
|
||||
1. `REDIS_CONN_STRING`:设置Redis作为缓存
|
||||
@@ -196,12 +196,12 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
- [图像接口(Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claude聊天接口(messages)](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Claude聊天接口](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Gemini聊天接口](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
## 相关项目
|
||||
- [One API](https://github.com/songquanpeng/one-api):原版项目
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持
|
||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代AI一站式B/C端解决方案
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度
|
||||
|
||||
其他基于New API的项目:
|
||||
|
||||
@@ -19,6 +19,7 @@ var TopUpLink = ""
|
||||
// var ChatLink = ""
|
||||
// var ChatLink2 = ""
|
||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
|
||||
var DisplayInCurrencyEnabled = true
|
||||
var DisplayTokenStatEnabled = true
|
||||
var DrawingEnabled = true
|
||||
|
||||
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
|
||||
var UsingMySQL = false
|
||||
var UsingClickHouse = false
|
||||
|
||||
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
APITypeXai
|
||||
APITypeCoze
|
||||
APITypeJimeng
|
||||
APITypeMoonshot
|
||||
APITypeSubmodel
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
APITypeMoonshot
|
||||
APITypeSubmodel
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
)
|
||||
|
||||
@@ -113,3 +113,64 @@ var ChannelBaseURLs = []string{
|
||||
"https://llm.submodel.ai", //53
|
||||
"https://ark.cn-beijing.volces.com", //54
|
||||
}
|
||||
|
||||
var ChannelTypeNames = map[int]string{
|
||||
ChannelTypeUnknown: "Unknown",
|
||||
ChannelTypeOpenAI: "OpenAI",
|
||||
ChannelTypeMidjourney: "Midjourney",
|
||||
ChannelTypeAzure: "Azure",
|
||||
ChannelTypeOllama: "Ollama",
|
||||
ChannelTypeMidjourneyPlus: "MidjourneyPlus",
|
||||
ChannelTypeOpenAIMax: "OpenAIMax",
|
||||
ChannelTypeOhMyGPT: "OhMyGPT",
|
||||
ChannelTypeCustom: "Custom",
|
||||
ChannelTypeAILS: "AILS",
|
||||
ChannelTypeAIProxy: "AIProxy",
|
||||
ChannelTypePaLM: "PaLM",
|
||||
ChannelTypeAPI2GPT: "API2GPT",
|
||||
ChannelTypeAIGC2D: "AIGC2D",
|
||||
ChannelTypeAnthropic: "Anthropic",
|
||||
ChannelTypeBaidu: "Baidu",
|
||||
ChannelTypeZhipu: "Zhipu",
|
||||
ChannelTypeAli: "Ali",
|
||||
ChannelTypeXunfei: "Xunfei",
|
||||
ChannelType360: "360",
|
||||
ChannelTypeOpenRouter: "OpenRouter",
|
||||
ChannelTypeAIProxyLibrary: "AIProxyLibrary",
|
||||
ChannelTypeFastGPT: "FastGPT",
|
||||
ChannelTypeTencent: "Tencent",
|
||||
ChannelTypeGemini: "Gemini",
|
||||
ChannelTypeMoonshot: "Moonshot",
|
||||
ChannelTypeZhipu_v4: "ZhipuV4",
|
||||
ChannelTypePerplexity: "Perplexity",
|
||||
ChannelTypeLingYiWanWu: "LingYiWanWu",
|
||||
ChannelTypeAws: "AWS",
|
||||
ChannelTypeCohere: "Cohere",
|
||||
ChannelTypeMiniMax: "MiniMax",
|
||||
ChannelTypeSunoAPI: "SunoAPI",
|
||||
ChannelTypeDify: "Dify",
|
||||
ChannelTypeJina: "Jina",
|
||||
ChannelCloudflare: "Cloudflare",
|
||||
ChannelTypeSiliconFlow: "SiliconFlow",
|
||||
ChannelTypeVertexAi: "VertexAI",
|
||||
ChannelTypeMistral: "Mistral",
|
||||
ChannelTypeDeepSeek: "DeepSeek",
|
||||
ChannelTypeMokaAI: "MokaAI",
|
||||
ChannelTypeVolcEngine: "VolcEngine",
|
||||
ChannelTypeBaiduV2: "BaiduV2",
|
||||
ChannelTypeXinference: "Xinference",
|
||||
ChannelTypeXai: "xAI",
|
||||
ChannelTypeCoze: "Coze",
|
||||
ChannelTypeKling: "Kling",
|
||||
ChannelTypeJimeng: "Jimeng",
|
||||
ChannelTypeVidu: "Vidu",
|
||||
ChannelTypeSubmodel: "Submodel",
|
||||
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||
}
|
||||
|
||||
func GetChannelTypeName(channelType int) string {
|
||||
if name, ok := ChannelTypeNames[channelType]; ok {
|
||||
return name
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/model"
|
||||
"one-api/setting/operation_setting"
|
||||
)
|
||||
|
||||
func GetSubscription(c *gin.Context) {
|
||||
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
|
||||
}
|
||||
quota := remainQuota + usedQuota
|
||||
amount := float64(quota)
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
amount /= common.QuotaPerUnit
|
||||
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
|
||||
// 我们将其解释为以“站点展示类型”为准:
|
||||
// - USD: 直接除以 QuotaPerUnit
|
||||
// - CNY: 先转 USD 再乘汇率
|
||||
// - TOKENS: 直接使用 tokens 数量
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
// amount 保持 tokens 数值
|
||||
default:
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
if token != nil && token.UnlimitedQuota {
|
||||
amount = 100000000
|
||||
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
amount := float64(quota)
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
amount /= common.QuotaPerUnit
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
// tokens 保持原值
|
||||
default:
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
usage := OpenAIUsageResponse{
|
||||
Object: "list",
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -40,46 +41,19 @@ type testResult struct {
|
||||
|
||||
func testChannel(channel *model.Channel, testModel string, endpointType string) testResult {
|
||||
tik := time.Now()
|
||||
if channel.Type == constant.ChannelTypeMidjourney {
|
||||
return testResult{
|
||||
localErr: errors.New("midjourney channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
var unsupportedTestChannelTypes = []int{
|
||||
constant.ChannelTypeMidjourney,
|
||||
constant.ChannelTypeMidjourneyPlus,
|
||||
constant.ChannelTypeSunoAPI,
|
||||
constant.ChannelTypeKling,
|
||||
constant.ChannelTypeJimeng,
|
||||
constant.ChannelTypeDoubaoVideo,
|
||||
constant.ChannelTypeVidu,
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeMidjourneyPlus {
|
||||
if lo.Contains(unsupportedTestChannelTypes, channel.Type) {
|
||||
channelTypeName := constant.GetChannelTypeName(channel.Type)
|
||||
return testResult{
|
||||
localErr: errors.New("midjourney plus channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeSunoAPI {
|
||||
return testResult{
|
||||
localErr: errors.New("suno channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeKling {
|
||||
return testResult{
|
||||
localErr: errors.New("kling channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeJimeng {
|
||||
return testResult{
|
||||
localErr: errors.New("jimeng channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeDoubaoVideo {
|
||||
return testResult{
|
||||
localErr: errors.New("doubao video channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeVidu {
|
||||
return testResult{
|
||||
localErr: errors.New("vidu channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
localErr: fmt.Errorf("%s channel test is not supported", channelTypeName),
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -66,18 +66,22 @@ func GetStatus(c *gin.Context) {
|
||||
"top_up_link": common.TopUpLink,
|
||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||
"quota_per_unit": common.QuotaPerUnit,
|
||||
"display_in_currency": common.DisplayInCurrencyEnabled,
|
||||
"enable_batch_update": common.BatchUpdateEnabled,
|
||||
"enable_drawing": common.DrawingEnabled,
|
||||
"enable_task": common.TaskEnabled,
|
||||
"enable_data_export": common.DataExportEnabled,
|
||||
"data_export_default_time": common.DataExportDefaultTime,
|
||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
|
||||
"display_in_currency": operation_setting.IsCurrencyDisplay(),
|
||||
"quota_display_type": operation_setting.GetQuotaDisplayType(),
|
||||
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
|
||||
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
|
||||
"enable_batch_update": common.BatchUpdateEnabled,
|
||||
"enable_drawing": common.DrawingEnabled,
|
||||
"enable_task": common.TaskEnabled,
|
||||
"enable_data_export": common.DataExportEnabled,
|
||||
"data_export_default_time": common.DataExportDefaultTime,
|
||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
||||
"price": operation_setting.Price,
|
||||
|
||||
@@ -178,4 +178,4 @@ func boolToString(b bool) string {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client {
|
||||
|
||||
func getPayMoney(amount int64, group string) float64 {
|
||||
dAmount := decimal.NewFromInt(amount)
|
||||
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
// 充值金额以“展示类型”为准:
|
||||
// - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
dAmount = dAmount.Div(dQuotaPerUnit)
|
||||
}
|
||||
@@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 {
|
||||
|
||||
func getMinTopup() int64 {
|
||||
minTopup := operation_setting.MinTopUp
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
|
||||
@@ -176,7 +177,7 @@ func RequestEpay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
amount := req.Amount
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dAmount := decimal.NewFromInt(int64(amount))
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
amount = dAmount.Div(dQuotaPerUnit).IntPart()
|
||||
|
||||
@@ -258,7 +258,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
|
||||
|
||||
func getStripePayMoney(amount float64, group string) float64 {
|
||||
originalAmount := amount
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
|
||||
@@ -279,7 +279,7 @@ func getStripePayMoney(amount float64, group string) float64 {
|
||||
|
||||
func getStripeMinTopup() int64 {
|
||||
minTopup := setting.StripeMinTopUp
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
minTopup = minTopup * int(common.QuotaPerUnit)
|
||||
}
|
||||
return int64(minTopup)
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
version: '3.4'
|
||||
# New-API Docker Compose Configuration
|
||||
#
|
||||
# Quick Start:
|
||||
# 1. docker-compose up -d
|
||||
# 2. Access at http://localhost:3000
|
||||
#
|
||||
# Using MySQL instead of PostgreSQL:
|
||||
# 1. Comment out the postgres service and SQL_DSN line 15
|
||||
# 2. Uncomment the mysql service and SQL_DSN line 16
|
||||
# 3. Uncomment mysql in depends_on (line 28)
|
||||
# 4. Uncomment mysql_data in volumes section (line 64)
|
||||
#
|
||||
# ⚠️ IMPORTANT: Change all default passwords before deploying to production!
|
||||
|
||||
version: '3.4' # For compatibility with older Docker versions
|
||||
|
||||
services:
|
||||
new-api:
|
||||
@@ -12,21 +26,22 @@ services:
|
||||
- ./data:/data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service
|
||||
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
|
||||
# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
|
||||
- REDIS_CONN_STRING=redis://redis
|
||||
- TZ=Asia/Shanghai
|
||||
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录
|
||||
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值
|
||||
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!!
|
||||
# - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment
|
||||
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
||||
# - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL
|
||||
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 batch update enabled
|
||||
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
|
||||
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!!
|
||||
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
||||
|
||||
depends_on:
|
||||
- redis
|
||||
- mysql
|
||||
- postgres
|
||||
# - mysql # Uncomment if using MySQL
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"]
|
||||
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -36,17 +51,31 @@ services:
|
||||
container_name: redis
|
||||
restart: always
|
||||
|
||||
mysql:
|
||||
image: mysql:8.2
|
||||
container_name: mysql
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: postgres
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN
|
||||
MYSQL_DATABASE: new-api
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
|
||||
POSTGRES_DB: new-api
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
# ports:
|
||||
# - "3306:3306" # If you want to access MySQL from outside Docker, uncomment
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
# ports:
|
||||
# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker
|
||||
|
||||
# mysql:
|
||||
# image: mysql:8.2
|
||||
# container_name: mysql
|
||||
# restart: always
|
||||
# environment:
|
||||
# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
|
||||
# MYSQL_DATABASE: new-api
|
||||
# volumes:
|
||||
# - mysql_data:/var/lib/mysql
|
||||
# ports:
|
||||
# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
pg_data:
|
||||
# mysql_data:
|
||||
|
||||
72
docs/translation-glossary.md
Normal file
72
docs/translation-glossary.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 翻译术语表 (Translation Glossary)
|
||||
|
||||
本文档为翻译贡献者提供项目中关键术语的标准翻译参考,以确保翻译的一致性和准确性。
|
||||
|
||||
This document provides standard translation references for key terminology in the project to ensure consistency and accuracy for translation contributors.
|
||||
|
||||
## 核心概念 (Core Concepts)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 倍率 | Ratio | 用于计算价格的乘数因子 | Multiplier factor used for price calculation |
|
||||
| 令牌 | Token | API访问凭证,也指模型处理的文本单元 | API access credentials or text units processed by models |
|
||||
| 渠道 | Channel | API服务提供商的接入通道 | Access channel for API service providers |
|
||||
| 分组 | Group | 用户或令牌的分类,影响价格倍率 | Classification of users or tokens, affecting price ratios |
|
||||
| 额度 | Quota | 用户可用的服务额度 | Available service quota for users |
|
||||
|
||||
## 模型相关 (Model Related)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 提示 | Prompt | 模型输入内容 | Model input content |
|
||||
| 补全 | Completion | 模型输出内容 | Model output content |
|
||||
| 输入 | Input/Prompt | 发送给模型的内容 | Content sent to the model |
|
||||
| 输出 | Output/Completion | 模型返回的内容 | Content returned by the model |
|
||||
| 模型倍率 | Model Ratio | 不同模型的计费倍率 | Billing ratio for different models |
|
||||
| 补全倍率 | Completion Ratio | 输出内容的额外计费倍率 | Additional billing ratio for output content |
|
||||
| 固定价格 | Price per call | 按次计费的价格 | Fixed price per call |
|
||||
| 按量计费 | Pay-as-you-go | 根据使用量计费 | Billing based on usage |
|
||||
| 按次计费 | Pay-per-view | 每次调用固定价格 | Fixed price per invocation |
|
||||
|
||||
## 用户管理 (User Management)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 超级管理员 | Root User | 最高权限管理员 | Administrator with highest privileges |
|
||||
| 管理员 | Admin User | 系统管理员 | System administrator |
|
||||
| 普通用户 | Normal User | 普通权限用户 | Regular user with standard privileges |
|
||||
|
||||
## 充值与兑换 (Recharge & Redemption)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 充值 | Top Up | 为账户增加额度 | Add quota to account |
|
||||
| 兑换码 | Redemption Code | 可兑换额度的代码 | Code that can be redeemed for quota |
|
||||
|
||||
## 渠道管理 (Channel Management)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 渠道 | Channel | API服务提供通道 | API service provider channel |
|
||||
| 密钥 | Key | API访问密钥 | API access key |
|
||||
| 优先级 | Priority | 渠道选择优先级 | Channel selection priority |
|
||||
| 权重 | Weight | 负载均衡权重 | Load balancing weight |
|
||||
| 代理 | Proxy | 代理服务器地址 | Proxy server address |
|
||||
| 模型重定向 | Model Mapping | 请求体中模型名称替换 | Model name replacement in request body |
|
||||
|
||||
## 翻译注意事项 (Translation Guidelines)
|
||||
|
||||
- **提示 (Prompt)** = 模型输入内容 / Model input content
|
||||
- **补全 (Completion)** = 模型输出内容 / Model output content
|
||||
- **倍率 (Ratio)** = 价格计算的乘数因子 / Multiplier factor for price calculation
|
||||
- **额度 (Quota)** = 可用的用户服务额度,有时也翻译为 Credit / Available service quota for users, sometimes also translated as Credit
|
||||
- **Token** = 根据上下文可能指 / Depending on context, may refer to:
|
||||
- API访问令牌 (API Token)
|
||||
- 模型处理的文本单元 (Text Token)
|
||||
- 系统访问令牌 (Access Token)
|
||||
|
||||
---
|
||||
|
||||
**贡献说明**: 如发现术语翻译不一致或有更好的翻译建议,欢迎提交 Issue 或 Pull Request。
|
||||
|
||||
**Contribution Note**: If you find any inconsistencies in terminology translations or have better translation suggestions, please feel free to submit an Issue or Pull Request.
|
||||
@@ -2,11 +2,12 @@ package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"one-api/common"
|
||||
"one-api/logger"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GeminiChatRequest struct {
|
||||
@@ -273,6 +274,7 @@ type GeminiChatGenerationConfig struct {
|
||||
ResponseModalities []string `json:"responseModalities,omitempty"`
|
||||
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
|
||||
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
|
||||
ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config
|
||||
}
|
||||
|
||||
type MediaResolution string
|
||||
|
||||
@@ -74,14 +74,15 @@ func (r ImageRequest) MarshalJSON() ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 不能合并ExtraFields!!!!!!!!
|
||||
// 合并 ExtraFields
|
||||
for k, v := range r.Extra {
|
||||
if _, exists := baseMap[k]; !exists {
|
||||
baseMap[k] = v
|
||||
}
|
||||
}
|
||||
//for k, v := range r.Extra {
|
||||
// if _, exists := baseMap[k]; !exists {
|
||||
// baseMap[k] = v
|
||||
// }
|
||||
//}
|
||||
|
||||
return json.Marshal(baseMap)
|
||||
return common.Marshal(baseMap)
|
||||
}
|
||||
|
||||
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
|
||||
|
||||
17
go.mod
17
go.mod
@@ -1,9 +1,7 @@
|
||||
module one-api
|
||||
|
||||
// +heroku goVersion go1.18
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.6
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/Calcium-Ion/go-epay v0.0.4
|
||||
@@ -13,7 +11,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0
|
||||
github.com/aws/smithy-go v1.22.5
|
||||
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
|
||||
github.com/bytedance/gopkg v0.1.3
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-contrib/gzip v0.0.6
|
||||
github.com/gin-contrib/sessions v0.0.5
|
||||
@@ -53,11 +51,10 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -84,7 +81,7 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
@@ -100,7 +97,7 @@ require (
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||
golang.org/x/arch v0.12.0 // indirect
|
||||
golang.org/x/arch v0.21.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
|
||||
33
go.sum
33
go.sum
@@ -23,18 +23,16 @@ github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0=
|
||||
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -142,10 +140,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
@@ -248,9 +244,8 @@ github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFi
|
||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
|
||||
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
@@ -262,7 +257,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -272,7 +266,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -320,5 +313,3 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"one-api/common"
|
||||
"one-api/setting/operation_setting"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) {
|
||||
}
|
||||
|
||||
func LogQuota(quota int) string {
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
return fmt.Sprintf("$%.6f 额度", float64(quota)/common.QuotaPerUnit)
|
||||
} else {
|
||||
// 新逻辑:根据额度展示类型输出
|
||||
q := float64(quota)
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
usd := q / common.QuotaPerUnit
|
||||
cny := usd * operation_setting.USDExchangeRate
|
||||
return fmt.Sprintf("¥%.6f 额度", cny)
|
||||
case operation_setting.QuotaDisplayTypeCustom:
|
||||
usd := q / common.QuotaPerUnit
|
||||
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
|
||||
if symbol == "" {
|
||||
symbol = "¤"
|
||||
}
|
||||
if rate <= 0 {
|
||||
rate = 1
|
||||
}
|
||||
v := usd * rate
|
||||
return fmt.Sprintf("%s%.6f 额度", symbol, v)
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return fmt.Sprintf("%d 点额度", quota)
|
||||
default: // USD
|
||||
return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
func FormatQuota(quota int) string {
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
return fmt.Sprintf("$%.6f", float64(quota)/common.QuotaPerUnit)
|
||||
} else {
|
||||
q := float64(quota)
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
usd := q / common.QuotaPerUnit
|
||||
cny := usd * operation_setting.USDExchangeRate
|
||||
return fmt.Sprintf("¥%.6f", cny)
|
||||
case operation_setting.QuotaDisplayTypeCustom:
|
||||
usd := q / common.QuotaPerUnit
|
||||
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
|
||||
if symbol == "" {
|
||||
symbol = "¤"
|
||||
}
|
||||
if rate <= 0 {
|
||||
rate = 1
|
||||
}
|
||||
v := usd * rate
|
||||
return fmt.Sprintf("%s%.6f", symbol, v)
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return fmt.Sprintf("%d", quota)
|
||||
default:
|
||||
return fmt.Sprintf("$%.6f", q/common.QuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
case "LogConsumeEnabled":
|
||||
common.LogConsumeEnabled = boolValue
|
||||
case "DisplayInCurrencyEnabled":
|
||||
common.DisplayInCurrencyEnabled = boolValue
|
||||
// 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效)
|
||||
// true -> USD, false -> TOKENS
|
||||
newVal := "USD"
|
||||
if !boolValue {
|
||||
newVal = "TOKENS"
|
||||
}
|
||||
if cfg := config.GlobalConfig.Get("general_setting"); cfg != nil {
|
||||
_ = config.UpdateConfigFromMap(cfg, map[string]string{"quota_display_type": newVal})
|
||||
}
|
||||
case "DisplayTokenStatEnabled":
|
||||
common.DisplayTokenStatEnabled = boolValue
|
||||
case "DrawingEnabled":
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"one-api/service"
|
||||
"one-api/setting/operation_setting"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +37,26 @@ func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Hea
|
||||
}
|
||||
}
|
||||
|
||||
// processHeaderOverride 处理请求头覆盖,支持变量替换
|
||||
// 支持的变量:{api_key}
|
||||
func processHeaderOverride(info *common.RelayInfo) (map[string]string, error) {
|
||||
headerOverride := make(map[string]string)
|
||||
for k, v := range info.HeadersOverride {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, types.NewError(nil, types.ErrorCodeChannelHeaderOverrideInvalid)
|
||||
}
|
||||
|
||||
// 替换支持的变量
|
||||
if strings.Contains(str, "{api_key}") {
|
||||
str = strings.ReplaceAll(str, "{api_key}", info.ApiKey)
|
||||
}
|
||||
|
||||
headerOverride[k] = str
|
||||
}
|
||||
return headerOverride, nil
|
||||
}
|
||||
|
||||
func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
fullRequestURL, err := a.GetRequestURL(info)
|
||||
if err != nil {
|
||||
@@ -49,13 +70,9 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
}
|
||||
headers := req.Header
|
||||
headerOverride := make(map[string]string)
|
||||
for k, v := range info.HeadersOverride {
|
||||
if str, ok := v.(string); ok {
|
||||
headerOverride[k] = str
|
||||
} else {
|
||||
return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid)
|
||||
}
|
||||
headerOverride, err := processHeaderOverride(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range headerOverride {
|
||||
headers.Set(key, value)
|
||||
@@ -86,13 +103,9 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
|
||||
// set form data
|
||||
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||
headers := req.Header
|
||||
headerOverride := make(map[string]string)
|
||||
for k, v := range info.HeadersOverride {
|
||||
if str, ok := v.(string); ok {
|
||||
headerOverride[k] = str
|
||||
} else {
|
||||
return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid)
|
||||
}
|
||||
headerOverride, err := processHeaderOverride(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range headerOverride {
|
||||
headers.Set(key, value)
|
||||
@@ -114,6 +127,13 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
||||
return nil, fmt.Errorf("get request url failed: %w", err)
|
||||
}
|
||||
targetHeader := http.Header{}
|
||||
headerOverride, err := processHeaderOverride(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range headerOverride {
|
||||
targetHeader.Set(key, value)
|
||||
}
|
||||
err = a.SetupRequestHeader(c, &targetHeader, info)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setup request header failed: %w", err)
|
||||
|
||||
@@ -910,6 +910,20 @@ func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.Ch
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeminiChatStreamHandler processes a streaming Gemini chat HTTP response, converts streamed chunks to OpenAI-style
|
||||
// streaming messages, forwards them to the client, and accumulates usage metrics.
|
||||
//
|
||||
// GeminiChatStreamHandler reads Gemini stream chunks, translates them into intermediate OpenAI-style stream
|
||||
// responses (including handling tool calls and inline media), sends an initial empty start response when the first
|
||||
// chunk arrives, streams subsequent deltas, and sends a final stop and usage response when the stream ends.
|
||||
// It tracks prompt/completion/reasoning token counts and accounts for inline images when computing usage.
|
||||
//
|
||||
// Parameters:
|
||||
// - c: the Gin request context used for writing responses and logging.
|
||||
// - info: relay metadata including upstream model name and token counts.
|
||||
// - resp: the upstream Gemini HTTP response body to consume.
|
||||
//
|
||||
// Returns the accumulated usage metrics on success, or a NewAPIError when no responses were received from Gemini.
|
||||
func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
// responseText := ""
|
||||
id := helper.GetResponseID(c)
|
||||
@@ -961,9 +975,15 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
// send first response
|
||||
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
||||
if response.IsToolCall() {
|
||||
emptyResponse.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 1)
|
||||
emptyResponse.Choices[0].Delta.ToolCalls[0] = *response.GetFirstToolCall()
|
||||
emptyResponse.Choices[0].Delta.ToolCalls[0].Function.Arguments = ""
|
||||
if len(emptyResponse.Choices) > 0 {
|
||||
toolCalls := response.Choices[0].Delta.ToolCalls
|
||||
copiedToolCalls := make([]dto.ToolCallResponse, len(toolCalls))
|
||||
for idx := range toolCalls {
|
||||
copiedToolCalls[idx] = toolCalls[idx]
|
||||
copiedToolCalls[idx].Function.Arguments = ""
|
||||
}
|
||||
emptyResponse.Choices[0].Delta.ToolCalls = copiedToolCalls
|
||||
}
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
err = handleStream(c, info, emptyResponse)
|
||||
if err != nil {
|
||||
@@ -1190,4 +1210,4 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
||||
openaiAdaptor := openai.Adaptor{}
|
||||
@@ -33,17 +35,25 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
|
||||
return info.ChannelBaseUrl + "/api/chat", nil
|
||||
if info.RelayMode == relayconstant.RelayModeEmbeddings {
|
||||
return info.ChannelBaseUrl + "/api/embed", nil
|
||||
}
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||
return info.ChannelBaseUrl + "/api/generate", nil
|
||||
}
|
||||
return info.ChannelBaseUrl + "/api/chat", nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
@@ -53,7 +63,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
if request == nil { return nil, errors.New("request is nil") }
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
// decide generate or chat
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||
return openAIToGenerate(c, request)
|
||||
@@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
return requestOpenAI2Embeddings(request), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
return channel.DoApiRequest(a, c, info, requestBody)
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
)
|
||||
|
||||
type OllamaChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaToolFunction struct {
|
||||
@@ -20,7 +20,7 @@ type OllamaToolFunction struct {
|
||||
}
|
||||
|
||||
type OllamaTool struct {
|
||||
Type string `json:"type"`
|
||||
Type string `json:"type"`
|
||||
Function OllamaToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
@@ -43,28 +43,27 @@ type OllamaChatRequest struct {
|
||||
}
|
||||
|
||||
type OllamaGenerateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Format interface{} `json:"format,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Format interface{} `json:"format,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||
Think json.RawMessage `json:"think,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaEmbeddingRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input interface{} `json:"input"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Input interface{} `json:"input"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaEmbeddingResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Embeddings [][]float64 `json:"embeddings"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Embeddings [][]float64 `json:"embeddings"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -35,13 +35,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
}
|
||||
|
||||
// options mapping
|
||||
if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP }
|
||||
if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK }
|
||||
if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
|
||||
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
|
||||
if r.Temperature != nil {
|
||||
chatReq.Options["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
chatReq.Options["top_p"] = r.TopP
|
||||
}
|
||||
if r.TopK != 0 {
|
||||
chatReq.Options["top_k"] = r.TopK
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
chatReq.Options["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
chatReq.Options["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
chatReq.Options["seed"] = int(r.Seed)
|
||||
}
|
||||
if mt := r.GetMaxTokens(); mt != 0 {
|
||||
chatReq.Options["num_predict"] = int(mt)
|
||||
}
|
||||
|
||||
if r.Stop != nil {
|
||||
switch v := r.Stop.(type) {
|
||||
@@ -50,21 +64,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
case []string:
|
||||
chatReq.Options["stop"] = v
|
||||
case []any:
|
||||
arr := make([]string,0,len(v))
|
||||
for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } }
|
||||
if len(arr)>0 { chatReq.Options["stop"] = arr }
|
||||
arr := make([]string, 0, len(v))
|
||||
for _, i := range v {
|
||||
if s, ok := i.(string); ok {
|
||||
arr = append(arr, s)
|
||||
}
|
||||
}
|
||||
if len(arr) > 0 {
|
||||
chatReq.Options["stop"] = arr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Tools) > 0 {
|
||||
tools := make([]OllamaTool,0,len(r.Tools))
|
||||
tools := make([]OllamaTool, 0, len(r.Tools))
|
||||
for _, t := range r.Tools {
|
||||
tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
|
||||
}
|
||||
chatReq.Tools = tools
|
||||
}
|
||||
|
||||
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
|
||||
chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages))
|
||||
for _, m := range r.Messages {
|
||||
var textBuilder strings.Builder
|
||||
var images []string
|
||||
@@ -79,14 +99,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
var base64Data string
|
||||
if strings.HasPrefix(img.Url, "http") {
|
||||
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
|
||||
if err != nil { return nil, err }
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base64Data = fileData.Base64Data
|
||||
} else if strings.HasPrefix(img.Url, "data:") {
|
||||
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] }
|
||||
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) {
|
||||
base64Data = img.Url[idx+1:]
|
||||
}
|
||||
} else {
|
||||
base64Data = img.Url
|
||||
}
|
||||
if base64Data != "" { images = append(images, base64Data) }
|
||||
if base64Data != "" {
|
||||
images = append(images, base64Data)
|
||||
}
|
||||
}
|
||||
} else if part.Type == dto.ContentTypeText {
|
||||
textBuilder.WriteString(part.Text)
|
||||
@@ -94,16 +120,24 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
}
|
||||
}
|
||||
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
|
||||
if len(images)>0 { cm.Images = images }
|
||||
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
|
||||
if len(images) > 0 {
|
||||
cm.Images = images
|
||||
}
|
||||
if m.Role == "tool" && m.Name != nil {
|
||||
cm.ToolName = *m.Name
|
||||
}
|
||||
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
|
||||
parsed := m.ParseToolCalls()
|
||||
if len(parsed) > 0 {
|
||||
calls := make([]OllamaToolCall,0,len(parsed))
|
||||
calls := make([]OllamaToolCall, 0, len(parsed))
|
||||
for _, tc := range parsed {
|
||||
var args interface{}
|
||||
if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
|
||||
if args==nil { args = map[string]any{} }
|
||||
if tc.Function.Arguments != "" {
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
oc := OllamaToolCall{}
|
||||
oc.Function.Name = tc.Function.Name
|
||||
oc.Function.Arguments = args
|
||||
@@ -132,28 +166,67 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
|
||||
gen.Prompt = v
|
||||
case []any:
|
||||
var sb strings.Builder
|
||||
for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } }
|
||||
for _, it := range v {
|
||||
if s, ok := it.(string); ok {
|
||||
sb.WriteString(s)
|
||||
}
|
||||
}
|
||||
gen.Prompt = sb.String()
|
||||
default:
|
||||
gen.Prompt = fmt.Sprintf("%v", r.Prompt)
|
||||
}
|
||||
}
|
||||
if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } }
|
||||
if r.ResponseFormat != nil {
|
||||
if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema }
|
||||
if r.Suffix != nil {
|
||||
if s, ok := r.Suffix.(string); ok {
|
||||
gen.Suffix = s
|
||||
}
|
||||
}
|
||||
if r.ResponseFormat != nil {
|
||||
if r.ResponseFormat.Type == "json" {
|
||||
gen.Format = "json"
|
||||
} else if r.ResponseFormat.Type == "json_schema" {
|
||||
var schema any
|
||||
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
|
||||
gen.Format = schema
|
||||
}
|
||||
}
|
||||
if r.Temperature != nil {
|
||||
gen.Options["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
gen.Options["top_p"] = r.TopP
|
||||
}
|
||||
if r.TopK != 0 {
|
||||
gen.Options["top_k"] = r.TopK
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
gen.Options["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
gen.Options["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
gen.Options["seed"] = int(r.Seed)
|
||||
}
|
||||
if mt := r.GetMaxTokens(); mt != 0 {
|
||||
gen.Options["num_predict"] = int(mt)
|
||||
}
|
||||
if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
|
||||
if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
|
||||
if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
|
||||
if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
|
||||
if r.Stop != nil {
|
||||
switch v := r.Stop.(type) {
|
||||
case string: gen.Options["stop"] = []string{v}
|
||||
case []string: gen.Options["stop"] = v
|
||||
case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr }
|
||||
case string:
|
||||
gen.Options["stop"] = []string{v}
|
||||
case []string:
|
||||
gen.Options["stop"] = v
|
||||
case []any:
|
||||
arr := make([]string, 0, len(v))
|
||||
for _, i := range v {
|
||||
if s, ok := i.(string); ok {
|
||||
arr = append(arr, s)
|
||||
}
|
||||
}
|
||||
if len(arr) > 0 {
|
||||
gen.Options["stop"] = arr
|
||||
}
|
||||
}
|
||||
}
|
||||
return gen, nil
|
||||
@@ -161,30 +234,51 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
|
||||
|
||||
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
|
||||
opts := map[string]any{}
|
||||
if r.Temperature != nil { opts["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { opts["top_p"] = r.TopP }
|
||||
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { opts["seed"] = int(r.Seed) }
|
||||
if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions }
|
||||
if r.Temperature != nil {
|
||||
opts["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
opts["top_p"] = r.TopP
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
opts["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
opts["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
opts["seed"] = int(r.Seed)
|
||||
}
|
||||
if r.Dimensions != 0 {
|
||||
opts["dimensions"] = r.Dimensions
|
||||
}
|
||||
input := r.ParseInput()
|
||||
if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} }
|
||||
return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions}
|
||||
if len(input) == 1 {
|
||||
return &OllamaEmbeddingRequest{Model: r.Model, Input: input[0], Options: opts, Dimensions: r.Dimensions}
|
||||
}
|
||||
return &OllamaEmbeddingRequest{Model: r.Model, Input: input, Options: opts, Dimensions: r.Dimensions}
|
||||
}
|
||||
|
||||
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
var oResp OllamaEmbeddingResponse
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings))
|
||||
for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) }
|
||||
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount}
|
||||
embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage}
|
||||
if err = common.Unmarshal(body, &oResp); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if oResp.Error != "" {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
data := make([]dto.OpenAIEmbeddingResponseItem, 0, len(oResp.Embeddings))
|
||||
for i, emb := range oResp.Embeddings {
|
||||
data = append(data, dto.OpenAIEmbeddingResponseItem{Index: i, Object: "embedding", Embedding: emb})
|
||||
}
|
||||
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens: 0, TotalTokens: oResp.PromptEvalCount}
|
||||
embResp := &dto.OpenAIEmbeddingResponse{Object: "list", Data: data, Model: info.UpstreamModelName, Usage: *usage}
|
||||
out, _ := common.Marshal(embResp)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,210 +1,278 @@
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
"time"
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ollamaChatStreamChunk struct {
|
||||
Model string `json:"model"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// chat
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking json.RawMessage `json:"thinking"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments interface{} `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
// generate
|
||||
Response string `json:"response"`
|
||||
Done bool `json:"done"`
|
||||
DoneReason string `json:"done_reason"`
|
||||
TotalDuration int64 `json:"total_duration"`
|
||||
LoadDuration int64 `json:"load_duration"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
||||
EvalDuration int64 `json:"eval_duration"`
|
||||
Model string `json:"model"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// chat
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking json.RawMessage `json:"thinking"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments interface{} `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
// generate
|
||||
Response string `json:"response"`
|
||||
Done bool `json:"done"`
|
||||
DoneReason string `json:"done_reason"`
|
||||
TotalDuration int64 `json:"total_duration"`
|
||||
LoadDuration int64 `json:"load_duration"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
||||
EvalDuration int64 `json:"eval_duration"`
|
||||
}
|
||||
|
||||
func toUnix(ts string) int64 {
|
||||
if ts == "" { return time.Now().Unix() }
|
||||
// try time.RFC3339 or with nanoseconds
|
||||
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
|
||||
return t.Unix()
|
||||
if ts == "" {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
// try time.RFC3339 or with nanoseconds
|
||||
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil {
|
||||
t2, err2 := time.Parse(time.RFC3339, ts)
|
||||
if err2 == nil {
|
||||
return t2.Unix()
|
||||
}
|
||||
return time.Now().Unix()
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest)
|
||||
}
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
usage := &dto.Usage{}
|
||||
var model = info.UpstreamModelName
|
||||
var responseId = common.GetUUID()
|
||||
var created = time.Now().Unix()
|
||||
var toolCallIndex int
|
||||
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
||||
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
helper.SetEventStreamHeaders(c)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
usage := &dto.Usage{}
|
||||
var model = info.UpstreamModelName
|
||||
var responseId = common.GetUUID()
|
||||
var created = time.Now().Unix()
|
||||
var toolCallIndex int
|
||||
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
||||
if data, err := common.Marshal(start); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" { continue }
|
||||
var chunk ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
||||
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if chunk.Model != "" { model = chunk.Model }
|
||||
created = toUnix(chunk.CreatedAt)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var chunk ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
||||
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if chunk.Model != "" {
|
||||
model = chunk.Model
|
||||
}
|
||||
created = toUnix(chunk.CreatedAt)
|
||||
|
||||
if !chunk.Done {
|
||||
// delta content
|
||||
var content string
|
||||
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
|
||||
delta := dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{ {
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
|
||||
} },
|
||||
}
|
||||
if content != "" { delta.Choices[0].Delta.SetContentString(content) }
|
||||
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||
if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
|
||||
}
|
||||
// tool calls
|
||||
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
||||
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
|
||||
for _, tc := range chunk.Message.ToolCalls {
|
||||
// arguments -> string
|
||||
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
||||
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
||||
tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
||||
tr.SetIndex(toolCallIndex)
|
||||
toolCallIndex++
|
||||
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
||||
}
|
||||
}
|
||||
if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
continue
|
||||
}
|
||||
// done frame
|
||||
// finalize once and break loop
|
||||
usage.PromptTokens = chunk.PromptEvalCount
|
||||
usage.CompletionTokens = chunk.EvalCount
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
finishReason := chunk.DoneReason
|
||||
if finishReason == "" { finishReason = "stop" }
|
||||
// emit stop delta
|
||||
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
||||
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
}
|
||||
// emit usage frame
|
||||
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
||||
if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
}
|
||||
// send [DONE]
|
||||
helper.Done(c)
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
|
||||
return usage, nil
|
||||
if !chunk.Done {
|
||||
// delta content
|
||||
var content string
|
||||
if chunk.Message != nil {
|
||||
content = chunk.Message.Content
|
||||
} else {
|
||||
content = chunk.Response
|
||||
}
|
||||
delta := dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"},
|
||||
}},
|
||||
}
|
||||
if content != "" {
|
||||
delta.Choices[0].Delta.SetContentString(content)
|
||||
}
|
||||
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
delta.Choices[0].Delta.SetReasoningContent(raw)
|
||||
}
|
||||
}
|
||||
// tool calls
|
||||
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
||||
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 0, len(chunk.Message.ToolCalls))
|
||||
for _, tc := range chunk.Message.ToolCalls {
|
||||
// arguments -> string
|
||||
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
||||
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
||||
tr := dto.ToolCallResponse{ID: toolId, Type: "function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
||||
tr.SetIndex(toolCallIndex)
|
||||
toolCallIndex++
|
||||
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
||||
}
|
||||
}
|
||||
if data, err := common.Marshal(delta); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// done frame
|
||||
// finalize once and break loop
|
||||
usage.PromptTokens = chunk.PromptEvalCount
|
||||
usage.CompletionTokens = chunk.EvalCount
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
finishReason := chunk.DoneReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
// emit stop delta
|
||||
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
||||
if data, err := common.Marshal(stop); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
}
|
||||
// emit usage frame
|
||||
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
||||
if data, err := common.Marshal(final); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
}
|
||||
// send [DONE]
|
||||
helper.Done(c)
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil && err != io.EOF {
|
||||
logger.LogError(c, "ollama stream scan error: "+err.Error())
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// non-stream handler for chat/generate
|
||||
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
raw := string(body)
|
||||
if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
raw := string(body)
|
||||
if common.DebugEnabled {
|
||||
println("ollama non-stream raw resp:", raw)
|
||||
}
|
||||
|
||||
lines := strings.Split(raw, "\n")
|
||||
var (
|
||||
aggContent strings.Builder
|
||||
reasoningBuilder strings.Builder
|
||||
lastChunk ollamaChatStreamChunk
|
||||
parsedAny bool
|
||||
)
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" { continue }
|
||||
var ck ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
||||
if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
continue
|
||||
}
|
||||
parsedAny = true
|
||||
lastChunk = ck
|
||||
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||
if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
|
||||
}
|
||||
if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
|
||||
}
|
||||
lines := strings.Split(raw, "\n")
|
||||
var (
|
||||
aggContent strings.Builder
|
||||
reasoningBuilder strings.Builder
|
||||
lastChunk ollamaChatStreamChunk
|
||||
parsedAny bool
|
||||
)
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
var ck ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
||||
if len(lines) == 1 {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parsedAny = true
|
||||
lastChunk = ck
|
||||
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
reasoningBuilder.WriteString(raw)
|
||||
}
|
||||
}
|
||||
if ck.Message != nil && ck.Message.Content != "" {
|
||||
aggContent.WriteString(ck.Message.Content)
|
||||
} else if ck.Response != "" {
|
||||
aggContent.WriteString(ck.Response)
|
||||
}
|
||||
}
|
||||
|
||||
if !parsedAny {
|
||||
var single ollamaChatStreamChunk
|
||||
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
lastChunk = single
|
||||
if single.Message != nil {
|
||||
if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
|
||||
aggContent.WriteString(single.Message.Content)
|
||||
} else { aggContent.WriteString(single.Response) }
|
||||
}
|
||||
if !parsedAny {
|
||||
var single ollamaChatStreamChunk
|
||||
if err := json.Unmarshal(body, &single); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
lastChunk = single
|
||||
if single.Message != nil {
|
||||
if len(single.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(single.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
reasoningBuilder.WriteString(raw)
|
||||
}
|
||||
}
|
||||
aggContent.WriteString(single.Message.Content)
|
||||
} else {
|
||||
aggContent.WriteString(single.Response)
|
||||
}
|
||||
}
|
||||
|
||||
model := lastChunk.Model
|
||||
if model == "" { model = info.UpstreamModelName }
|
||||
created := toUnix(lastChunk.CreatedAt)
|
||||
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
||||
content := aggContent.String()
|
||||
finishReason := lastChunk.DoneReason
|
||||
if finishReason == "" { finishReason = "stop" }
|
||||
model := lastChunk.Model
|
||||
if model == "" {
|
||||
model = info.UpstreamModelName
|
||||
}
|
||||
created := toUnix(lastChunk.CreatedAt)
|
||||
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
||||
content := aggContent.String()
|
||||
finishReason := lastChunk.DoneReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||
if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
|
||||
full := dto.OpenAITextResponse{
|
||||
Id: common.GetUUID(),
|
||||
Model: model,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Choices: []dto.OpenAITextResponseChoice{ {
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
} },
|
||||
Usage: *usage,
|
||||
}
|
||||
out, _ := common.Marshal(full)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||
if rc := reasoningBuilder.String(); rc != "" {
|
||||
msg.ReasoningContent = rc
|
||||
}
|
||||
full := dto.OpenAITextResponse{
|
||||
Id: common.GetUUID(),
|
||||
Model: model,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Choices: []dto.OpenAITextResponseChoice{{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
}},
|
||||
Usage: *usage,
|
||||
}
|
||||
out, _ := common.Marshal(full)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func contentPtr(s string) *string { if s=="" { return nil }; return &s }
|
||||
func contentPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -61,6 +61,16 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
// SiliconFlow requires messages array for FIM requests, even if client doesn't send it
|
||||
if (request.Prefix != nil || request.Suffix != nil) && len(request.Messages) == 0 {
|
||||
// Add an empty user message to satisfy SiliconFlow's requirement
|
||||
request.Messages = []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,4 +13,4 @@ var ModelList = []string{
|
||||
"deepseek-ai/DeepSeek-V3.1",
|
||||
}
|
||||
|
||||
const ChannelName = "submodel"
|
||||
const ChannelName = "submodel"
|
||||
|
||||
@@ -275,7 +275,9 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA
|
||||
return nil, errors.New("field prompt is required")
|
||||
}
|
||||
case relayconstant.RelayModeChatCompletions:
|
||||
if len(textRequest.Messages) == 0 {
|
||||
// For FIM (Fill-in-the-middle) requests with prefix/suffix, messages is optional
|
||||
// It will be filled by provider-specific adaptors if needed (e.g., SiliconFlow)。Or it is allowed by model vendor(s) (e.g., DeepSeek)
|
||||
if len(textRequest.Messages) == 0 && textRequest.Prefix == nil && textRequest.Suffix == nil {
|
||||
return nil, errors.New("field messages is required")
|
||||
}
|
||||
case relayconstant.RelayModeEmbeddings:
|
||||
|
||||
@@ -2,17 +2,34 @@ package operation_setting
|
||||
|
||||
import "one-api/setting/config"
|
||||
|
||||
// 额度展示类型
|
||||
const (
|
||||
QuotaDisplayTypeUSD = "USD"
|
||||
QuotaDisplayTypeCNY = "CNY"
|
||||
QuotaDisplayTypeTokens = "TOKENS"
|
||||
QuotaDisplayTypeCustom = "CUSTOM"
|
||||
)
|
||||
|
||||
type GeneralSetting struct {
|
||||
DocsLink string `json:"docs_link"`
|
||||
PingIntervalEnabled bool `json:"ping_interval_enabled"`
|
||||
PingIntervalSeconds int `json:"ping_interval_seconds"`
|
||||
// 当前站点额度展示类型:USD / CNY / TOKENS
|
||||
QuotaDisplayType string `json:"quota_display_type"`
|
||||
// 自定义货币符号,用于 CUSTOM 展示类型
|
||||
CustomCurrencySymbol string `json:"custom_currency_symbol"`
|
||||
// 自定义货币与美元汇率(1 USD = X Custom)
|
||||
CustomCurrencyExchangeRate float64 `json:"custom_currency_exchange_rate"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var generalSetting = GeneralSetting{
|
||||
DocsLink: "https://docs.newapi.pro",
|
||||
PingIntervalEnabled: false,
|
||||
PingIntervalSeconds: 60,
|
||||
DocsLink: "https://docs.newapi.pro",
|
||||
PingIntervalEnabled: false,
|
||||
PingIntervalSeconds: 60,
|
||||
QuotaDisplayType: QuotaDisplayTypeUSD,
|
||||
CustomCurrencySymbol: "¤",
|
||||
CustomCurrencyExchangeRate: 1.0,
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -23,3 +40,52 @@ func init() {
|
||||
func GetGeneralSetting() *GeneralSetting {
|
||||
return &generalSetting
|
||||
}
|
||||
|
||||
// IsCurrencyDisplay 是否以货币形式展示(美元或人民币)
|
||||
func IsCurrencyDisplay() bool {
|
||||
return generalSetting.QuotaDisplayType != QuotaDisplayTypeTokens
|
||||
}
|
||||
|
||||
// IsCNYDisplay 是否以人民币展示
|
||||
func IsCNYDisplay() bool {
|
||||
return generalSetting.QuotaDisplayType == QuotaDisplayTypeCNY
|
||||
}
|
||||
|
||||
// GetQuotaDisplayType 返回额度展示类型
|
||||
func GetQuotaDisplayType() string {
|
||||
return generalSetting.QuotaDisplayType
|
||||
}
|
||||
|
||||
// GetCurrencySymbol 返回当前展示类型对应符号
|
||||
func GetCurrencySymbol() string {
|
||||
switch generalSetting.QuotaDisplayType {
|
||||
case QuotaDisplayTypeUSD:
|
||||
return "$"
|
||||
case QuotaDisplayTypeCNY:
|
||||
return "¥"
|
||||
case QuotaDisplayTypeCustom:
|
||||
if generalSetting.CustomCurrencySymbol != "" {
|
||||
return generalSetting.CustomCurrencySymbol
|
||||
}
|
||||
return "¤"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetUsdToCurrencyRate 返回 1 USD = X <currency> 的 X(TOKENS 不适用)
|
||||
func GetUsdToCurrencyRate(usdToCny float64) float64 {
|
||||
switch generalSetting.QuotaDisplayType {
|
||||
case QuotaDisplayTypeUSD:
|
||||
return 1
|
||||
case QuotaDisplayTypeCNY:
|
||||
return usdToCny
|
||||
case QuotaDisplayTypeCustom:
|
||||
if generalSetting.CustomCurrencyExchangeRate > 0 {
|
||||
return generalSetting.CustomCurrencyExchangeRate
|
||||
}
|
||||
return 1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>New API</title>
|
||||
<analytics></analytics>
|
||||
<analytics></analytics>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({
|
||||
autoFocus
|
||||
/>
|
||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')}
|
||||
{t(
|
||||
'支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,14 +142,6 @@ const FooterBar = () => {
|
||||
>
|
||||
Midjourney-Proxy
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Deeptrain-Community/chatnio'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
chatnio
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/neko-api-key-tool'
|
||||
target='_blank'
|
||||
@@ -163,7 +155,7 @@ const FooterBar = () => {
|
||||
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('基于New API的项目')}
|
||||
{t('友情链接')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
@@ -174,7 +166,22 @@ const FooterBar = () => {
|
||||
>
|
||||
new-api-horizon
|
||||
</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
|
||||
<a
|
||||
href='https://github.com/coaidev/coai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
CoAI
|
||||
</a>
|
||||
<a
|
||||
href='https://www.gpt-load.com/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
GPT-Load
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,15 +207,6 @@ const FooterBar = () => {
|
||||
>
|
||||
New API
|
||||
</a>
|
||||
<span className='!text-semi-color-text-1'> & </span>
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
One API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -223,10 +221,23 @@ const FooterBar = () => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{footer ? (
|
||||
<div
|
||||
className='custom-footer'
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='custom-footer'
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
<div className='absolute bottom-2 right-4 text-xs !text-semi-color-text-2 opacity-70'>
|
||||
<span>{t('设计与开发由')} </span>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
New API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
customFooter
|
||||
)}
|
||||
|
||||
@@ -48,9 +48,19 @@ const PageLayout = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldHideFooter =
|
||||
location.pathname.startsWith('/console') ||
|
||||
location.pathname === '/pricing';
|
||||
const cardProPages = [
|
||||
'/console/channel',
|
||||
'/console/log',
|
||||
'/console/redemption',
|
||||
'/console/user',
|
||||
'/console/token',
|
||||
'/console/midjourney',
|
||||
'/console/task',
|
||||
'/console/models',
|
||||
'/pricing',
|
||||
];
|
||||
|
||||
const shouldHideFooter = cardProPages.includes(location.pathname);
|
||||
|
||||
const shouldInnerPadding =
|
||||
location.pathname.includes('/console') &&
|
||||
|
||||
@@ -42,7 +42,7 @@ const OperationSetting = () => {
|
||||
QuotaPerUnit: 0,
|
||||
USDExchangeRate: 0,
|
||||
RetryTimes: 0,
|
||||
DisplayInCurrencyEnabled: false,
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
DisplayTokenStatEnabled: false,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
|
||||
@@ -46,7 +46,6 @@ import { useTranslation } from 'react-i18next';
|
||||
const SystemSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
|
||||
PasswordLoginEnabled: '',
|
||||
PasswordRegisterEnabled: '',
|
||||
EmailVerificationEnabled: '',
|
||||
@@ -212,7 +211,9 @@ const SystemSetting = () => {
|
||||
setInputs(newInputs);
|
||||
setOriginInputs(newInputs);
|
||||
// 同步模式布尔到本地状态
|
||||
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
|
||||
if (
|
||||
typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined'
|
||||
) {
|
||||
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
|
||||
}
|
||||
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
|
||||
@@ -749,14 +750,17 @@ const SystemSetting = () => {
|
||||
noLabel
|
||||
extraText={t('SSRF防护开关详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
|
||||
handleCheckboxChange(
|
||||
'fetch_setting.enable_ssrf_protection',
|
||||
e,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('启用SSRF防护(推荐开启以保护服务器安全)')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -767,14 +771,19 @@ const SystemSetting = () => {
|
||||
noLabel
|
||||
extraText={t('私有IP访问详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.allow_private_ip', e)
|
||||
handleCheckboxChange(
|
||||
'fetch_setting.allow_private_ip',
|
||||
e,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')}
|
||||
{t(
|
||||
'允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)',
|
||||
)}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -785,7 +794,10 @@ const SystemSetting = () => {
|
||||
noLabel
|
||||
extraText={t('域名IP过滤详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
|
||||
handleCheckboxChange(
|
||||
'fetch_setting.apply_ip_filter_for_domain',
|
||||
e,
|
||||
)
|
||||
}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
@@ -794,17 +806,23 @@ const SystemSetting = () => {
|
||||
<Text strong>
|
||||
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('支持通配符格式,如:example.com, *.api.example.com')}
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t(
|
||||
'支持通配符格式,如:example.com, *.api.example.com',
|
||||
)}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
type='button'
|
||||
value={domainFilterMode ? 'whitelist' : 'blacklist'}
|
||||
onChange={(val) => {
|
||||
const selected = val && val.target ? val.target.value : val;
|
||||
const selected =
|
||||
val && val.target ? val.target.value : val;
|
||||
const isWhitelist = selected === 'whitelist';
|
||||
setDomainFilterMode(isWhitelist);
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.domain_filter_mode': isWhitelist,
|
||||
}));
|
||||
@@ -819,9 +837,9 @@ const SystemSetting = () => {
|
||||
onChange={(value) => {
|
||||
setDomainList(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.domain_list': value
|
||||
'fetch_setting.domain_list': value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入域名后回车,如:example.com')}
|
||||
@@ -838,17 +856,21 @@ const SystemSetting = () => {
|
||||
<Text strong>
|
||||
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
type='button'
|
||||
value={ipFilterMode ? 'whitelist' : 'blacklist'}
|
||||
onChange={(val) => {
|
||||
const selected = val && val.target ? val.target.value : val;
|
||||
const selected =
|
||||
val && val.target ? val.target.value : val;
|
||||
const isWhitelist = selected === 'whitelist';
|
||||
setIpFilterMode(isWhitelist);
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.ip_filter_mode': isWhitelist,
|
||||
}));
|
||||
@@ -863,9 +885,9 @@ const SystemSetting = () => {
|
||||
onChange={(value) => {
|
||||
setIpList(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.ip_list': value
|
||||
'fetch_setting.ip_list': value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
|
||||
@@ -880,7 +902,10 @@ const SystemSetting = () => {
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Text strong>{t('允许的端口')}</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
|
||||
</Text>
|
||||
<TagInput
|
||||
@@ -888,15 +913,18 @@ const SystemSetting = () => {
|
||||
onChange={(value) => {
|
||||
setAllowedPorts(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.allowed_ports': value
|
||||
'fetch_setting.allowed_ports': value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入端口后回车,如:80 或 8000-8999')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t('端口配置详细说明')}
|
||||
</Text>
|
||||
</Col>
|
||||
|
||||
@@ -91,7 +91,8 @@ const AccountManagement = ({
|
||||
);
|
||||
};
|
||||
const isBound = (accountId) => Boolean(accountId);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] =
|
||||
React.useState(false);
|
||||
const passkeyEnabled = passkeyStatus?.enabled;
|
||||
const lastUsedLabel = passkeyStatus?.last_used_at
|
||||
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
||||
@@ -236,7 +237,8 @@ const AccountManagement = ({
|
||||
onGitHubOAuthClicked(status.github_client_id)
|
||||
}
|
||||
disabled={
|
||||
isBound(userState.user?.github_id) || !status.github_oauth
|
||||
isBound(userState.user?.github_id) ||
|
||||
!status.github_oauth
|
||||
}
|
||||
>
|
||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||
@@ -394,7 +396,8 @@ const AccountManagement = ({
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||
}
|
||||
disabled={
|
||||
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
|
||||
isBound(userState.user?.linux_do_id) ||
|
||||
!status.linuxdo_oauth
|
||||
}
|
||||
>
|
||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||
|
||||
@@ -91,23 +91,7 @@ const REGION_EXAMPLE = {
|
||||
|
||||
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||
const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1,
|
||||
4,
|
||||
14,
|
||||
34,
|
||||
17,
|
||||
26,
|
||||
24,
|
||||
47,
|
||||
25,
|
||||
20,
|
||||
23,
|
||||
31,
|
||||
35,
|
||||
40,
|
||||
42,
|
||||
48,
|
||||
43,
|
||||
1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
|
||||
]);
|
||||
|
||||
function type2secretPrompt(type) {
|
||||
@@ -279,8 +263,8 @@ const EditChannelModal = (props) => {
|
||||
const scrollToSection = (sectionKey) => {
|
||||
const sectionElement = formSectionRefs.current[sectionKey];
|
||||
if (sectionElement) {
|
||||
sectionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
sectionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
@@ -301,7 +285,7 @@ const EditChannelModal = (props) => {
|
||||
} else {
|
||||
newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
setCurrentSectionIndex(newIndex);
|
||||
scrollToSection(availableSections[newIndex]);
|
||||
};
|
||||
@@ -423,7 +407,10 @@ const EditChannelModal = (props) => {
|
||||
break;
|
||||
case 45:
|
||||
localModels = getChannelModels(value);
|
||||
setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' }));
|
||||
setInputs((prevInputs) => ({
|
||||
...prevInputs,
|
||||
base_url: 'https://ark.cn-beijing.volces.com',
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
@@ -517,7 +504,8 @@ const EditChannelModal = (props) => {
|
||||
// 读取 Vertex 密钥格式
|
||||
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
||||
// 读取企业账户设置
|
||||
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
|
||||
data.is_enterprise_account =
|
||||
parsedSettings.openrouter_enterprise === true;
|
||||
// 读取字段透传控制设置
|
||||
data.allow_service_tier = parsedSettings.allow_service_tier || false;
|
||||
data.disable_store = parsedSettings.disable_store || false;
|
||||
@@ -944,7 +932,10 @@ const EditChannelModal = (props) => {
|
||||
showInfo(t('请至少选择一个模型!'));
|
||||
return;
|
||||
}
|
||||
if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) {
|
||||
if (
|
||||
localInputs.type === 45 &&
|
||||
(!localInputs.base_url || localInputs.base_url.trim() === '')
|
||||
) {
|
||||
showInfo(t('请输入API地址!'));
|
||||
return;
|
||||
}
|
||||
@@ -989,7 +980,8 @@ const EditChannelModal = (props) => {
|
||||
|
||||
// type === 20: 设置企业账户标识,无论是true还是false都要传到后端
|
||||
if (localInputs.type === 20) {
|
||||
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
|
||||
settings.openrouter_enterprise =
|
||||
localInputs.is_enterprise_account === true;
|
||||
}
|
||||
|
||||
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
|
||||
@@ -1340,7 +1332,7 @@ const EditChannelModal = (props) => {
|
||||
type='tertiary'
|
||||
icon={<IconChevronUp />}
|
||||
onClick={() => navigateToSection('up')}
|
||||
style={{
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
@@ -1356,7 +1348,7 @@ const EditChannelModal = (props) => {
|
||||
type='tertiary'
|
||||
icon={<IconChevronDown />}
|
||||
onClick={() => navigateToSection('down')}
|
||||
style={{
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
@@ -1398,8 +1390,8 @@ const EditChannelModal = (props) => {
|
||||
>
|
||||
{() => (
|
||||
<Spin spinning={loading}>
|
||||
<div
|
||||
className='p-2'
|
||||
<div
|
||||
className='p-2'
|
||||
ref={formContainerRef}
|
||||
>
|
||||
<div ref={el => formSectionRefs.current.basicInfo = el}>
|
||||
@@ -1448,7 +1440,9 @@ const EditChannelModal = (props) => {
|
||||
setIsEnterpriseAccount(value);
|
||||
handleInputChange('is_enterprise_account', value);
|
||||
}}
|
||||
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
|
||||
extraText={t(
|
||||
'企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选',
|
||||
)}
|
||||
initValue={inputs.is_enterprise_account}
|
||||
/>
|
||||
)}
|
||||
@@ -2076,27 +2070,27 @@ const EditChannelModal = (props) => {
|
||||
)}
|
||||
|
||||
{inputs.type === 45 && (
|
||||
<div>
|
||||
<Form.Select
|
||||
field='base_url'
|
||||
label={t('API地址')}
|
||||
placeholder={t('请选择API地址')}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
optionList={[
|
||||
{
|
||||
value: 'https://ark.cn-beijing.volces.com',
|
||||
label: 'https://ark.cn-beijing.volces.com'
|
||||
},
|
||||
{
|
||||
value: 'https://ark.ap-southeast.bytepluses.com',
|
||||
label: 'https://ark.ap-southeast.bytepluses.com'
|
||||
}
|
||||
]}
|
||||
defaultValue='https://ark.cn-beijing.volces.com'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form.Select
|
||||
field='base_url'
|
||||
label={t('API地址')}
|
||||
placeholder={t('请选择API地址')}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
optionList={[
|
||||
{
|
||||
value: 'https://ark.cn-beijing.volces.com',
|
||||
label: 'https://ark.cn-beijing.volces.com',
|
||||
},
|
||||
{
|
||||
value: 'https://ark.ap-southeast.bytepluses.com',
|
||||
label: 'https://ark.ap-southeast.bytepluses.com',
|
||||
},
|
||||
]}
|
||||
defaultValue='https://ark.cn-beijing.volces.com'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
@@ -2467,32 +2461,44 @@ const EditChannelModal = (props) => {
|
||||
t('此项可选,用于覆盖请求头参数') +
|
||||
'\n' +
|
||||
t('格式示例:') +
|
||||
'\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0"\n}'
|
||||
'\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}'
|
||||
}
|
||||
autosize
|
||||
onChange={(value) =>
|
||||
handleInputChange('header_override', value)
|
||||
}
|
||||
extraText={
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'header_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('格式模板')}
|
||||
</Text>
|
||||
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex gap-2 flex-wrap items-center'>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() =>
|
||||
handleInputChange(
|
||||
'header_override',
|
||||
JSON.stringify(
|
||||
{
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
'Authorization': 'Bearer{api_key}',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('支持变量:')}
|
||||
</Text>
|
||||
<div className='text-xs text-tertiary ml-2'>
|
||||
<div>{t('渠道密钥')}: {'{api_key}'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
|
||||
@@ -56,10 +56,10 @@ const MjLogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
end: preset.end(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
|
||||
const currencyItems = [
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'CNY', label: 'CNY (¥)' },
|
||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||
];
|
||||
|
||||
const handleChange = (value) => {
|
||||
|
||||
@@ -107,6 +107,7 @@ const SearchActions = memo(
|
||||
optionList={[
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'CNY', label: 'CNY' },
|
||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -36,8 +36,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||
TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE
|
||||
TASK_ACTION_GENERATE,
|
||||
TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE,
|
||||
} from '../../../constants/common.constant';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ const TaskLogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
end: preset.end(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -60,38 +60,54 @@ const ContentModal = ({
|
||||
if (videoError) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '16px' }}
|
||||
>
|
||||
视频无法在当前浏览器中播放,这可能是由于:
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
|
||||
>
|
||||
• 视频服务商的跨域限制
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
|
||||
>
|
||||
• 需要特定的请求头或认证
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}
|
||||
>
|
||||
• 防盗链保护机制
|
||||
</Text>
|
||||
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
<Button
|
||||
icon={<IconExternalOpen />}
|
||||
onClick={handleOpenInNewTab}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
在新标签页中打开
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
<Button icon={<IconCopy />} onClick={handleCopyUrl}>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<Text
|
||||
type="tertiary"
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ fontSize: '10px', wordBreak: 'break-all' }}
|
||||
>
|
||||
{modalContent}
|
||||
@@ -104,22 +120,24 @@ const ContentModal = ({
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{isLoading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
src={modalContent}
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
<video
|
||||
src={modalContent}
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
autoPlay
|
||||
crossOrigin="anonymous"
|
||||
crossOrigin='anonymous'
|
||||
onError={handleVideoError}
|
||||
onLoadedData={handleVideoLoaded}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
@@ -134,10 +152,10 @@ const ContentModal = ({
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{
|
||||
height: isVideo ? '450px' : '400px',
|
||||
bodyStyle={{
|
||||
height: isVideo ? '450px' : '400px',
|
||||
overflow: 'auto',
|
||||
padding: isVideo && videoError ? '0' : '24px'
|
||||
padding: isVideo && videoError ? '0' : '24px',
|
||||
}}
|
||||
width={800}
|
||||
>
|
||||
|
||||
@@ -419,7 +419,7 @@ export const getLogsColumns = ({
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROMPT,
|
||||
title: t('提示'),
|
||||
title: t('输入'),
|
||||
dataIndex: 'prompt_tokens',
|
||||
render: (text, record, index) => {
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
@@ -431,7 +431,7 @@ export const getLogsColumns = ({
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.COMPLETION,
|
||||
title: t('补全'),
|
||||
title: t('输出'),
|
||||
dataIndex: 'completion_tokens',
|
||||
render: (text, record, index) => {
|
||||
return parseInt(text) > 0 &&
|
||||
|
||||
@@ -57,10 +57,10 @@ const LogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
end: preset.end(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,12 +30,14 @@ import {
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Spin, Tooltip
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
||||
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
|
||||
import { IconGift } from '@douyinfe/semi-icons';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -266,7 +268,8 @@ const RechargeCard = ({
|
||||
{payMethods && payMethods.length > 0 ? (
|
||||
<Space wrap>
|
||||
{payMethods.map((payMethod) => {
|
||||
const minTopupVal = Number(payMethod.min_topup) || 0;
|
||||
const minTopupVal =
|
||||
Number(payMethod.min_topup) || 0;
|
||||
const isStripe = payMethod.type === 'stripe';
|
||||
const disabled =
|
||||
(!enableOnlineTopUp && !isStripe) ||
|
||||
@@ -280,7 +283,9 @@ const RechargeCard = ({
|
||||
type='tertiary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
disabled={disabled}
|
||||
loading={paymentLoading && payWay === payMethod.type}
|
||||
loading={
|
||||
paymentLoading && payWay === payMethod.type
|
||||
}
|
||||
icon={
|
||||
payMethod.type === 'alipay' ? (
|
||||
<SiAlipay size={18} color='#1677FF' />
|
||||
@@ -291,7 +296,10 @@ const RechargeCard = ({
|
||||
) : (
|
||||
<CreditCard
|
||||
size={18}
|
||||
color={payMethod.color || 'var(--semi-color-text-2)'}
|
||||
color={
|
||||
payMethod.color ||
|
||||
'var(--semi-color-text-2)'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -301,12 +309,22 @@ const RechargeCard = ({
|
||||
</Button>
|
||||
);
|
||||
|
||||
return disabled && minTopupVal > Number(topUpCount || 0) ? (
|
||||
<Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
|
||||
return disabled &&
|
||||
minTopupVal > Number(topUpCount || 0) ? (
|
||||
<Tooltip
|
||||
content={
|
||||
t('此支付方式最低充值金额为') +
|
||||
' ' +
|
||||
minTopupVal
|
||||
}
|
||||
key={payMethod.type}
|
||||
>
|
||||
{buttonEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
|
||||
<React.Fragment key={payMethod.type}>
|
||||
{buttonEl}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
@@ -321,26 +339,75 @@ const RechargeCard = ({
|
||||
)}
|
||||
|
||||
{(enableOnlineTopUp || enableStripeTopUp) && (
|
||||
<Form.Slot label={t('选择充值额度')}>
|
||||
<Form.Slot
|
||||
label={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{t('选择充值额度')}</span>
|
||||
{(() => {
|
||||
const { symbol, rate, type } = getCurrencyConfig();
|
||||
if (type === 'USD') return null;
|
||||
|
||||
return (
|
||||
<span style={{ color: 'var(--semi-color-text-2)', fontSize: '12px', fontWeight: 'normal' }}>
|
||||
(1 $ = {rate.toFixed(2)} {symbol})
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||
{presetAmounts.map((preset, index) => {
|
||||
const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
|
||||
const discount =
|
||||
preset.discount ||
|
||||
topupInfo?.discount?.[preset.value] ||
|
||||
1.0;
|
||||
const originalPrice = preset.value * priceRatio;
|
||||
const discountedPrice = originalPrice * discount;
|
||||
const hasDiscount = discount < 1.0;
|
||||
const actualPay = discountedPrice;
|
||||
const save = originalPrice - discountedPrice;
|
||||
|
||||
// 根据当前货币类型换算显示金额和数量
|
||||
const { symbol, rate, type } = getCurrencyConfig();
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let usdRate = 7; // 默认CNY汇率
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
usdRate = s?.usd_exchange_rate || 7;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let displayValue = preset.value; // 显示的数量
|
||||
let displayActualPay = actualPay;
|
||||
let displaySave = save;
|
||||
|
||||
if (type === 'USD') {
|
||||
// 数量保持USD,价格从CNY转USD
|
||||
displayActualPay = actualPay / usdRate;
|
||||
displaySave = save / usdRate;
|
||||
} else if (type === 'CNY') {
|
||||
// 数量转CNY,价格已是CNY
|
||||
displayValue = preset.value * usdRate;
|
||||
} else if (type === 'CUSTOM') {
|
||||
// 数量和价格都转自定义货币
|
||||
displayValue = preset.value * rate;
|
||||
displayActualPay = (actualPay / usdRate) * rate;
|
||||
displaySave = (save / usdRate) * rate;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedPreset === preset.value
|
||||
? '2px solid var(--semi-color-primary)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
border:
|
||||
selectedPreset === preset.value
|
||||
? '2px solid var(--semi-color-primary)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
onClick={() => {
|
||||
@@ -352,24 +419,35 @@ const RechargeCard = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
|
||||
<Typography.Title
|
||||
heading={6}
|
||||
style={{ margin: '0 0 8px 0' }}
|
||||
>
|
||||
<Coins size={18} />
|
||||
{formatLargeNumber(preset.value)}
|
||||
{formatLargeNumber(preset.value)} $
|
||||
{hasDiscount && (
|
||||
<Tag style={{ marginLeft: 4 }} color="green">
|
||||
{t('折').includes('off') ?
|
||||
((1 - parseFloat(discount)) * 100).toFixed(1) :
|
||||
(discount * 10).toFixed(1)}{t('折')}
|
||||
</Tag>
|
||||
<Tag style={{ marginLeft: 4 }} color='green'>
|
||||
{t('折').includes('off')
|
||||
? (
|
||||
(1 - parseFloat(discount)) *
|
||||
100
|
||||
).toFixed(1)
|
||||
: (discount * 10).toFixed(1)}
|
||||
{t('折')}
|
||||
</Tag>
|
||||
)}
|
||||
</Typography.Title>
|
||||
<div style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
margin: '4px 0'
|
||||
}}>
|
||||
{t('实付')} {actualPay.toFixed(2)},
|
||||
{hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
margin: '4px 0',
|
||||
}}
|
||||
>
|
||||
{t('实付')} {symbol}{displayActualPay.toFixed(2)},
|
||||
{hasDiscount
|
||||
? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
|
||||
: `${t('节省')} ${symbol}0.00`}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -80,11 +80,11 @@ const TopUp = () => {
|
||||
// 预设充值额度选项
|
||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
|
||||
|
||||
// 充值配置信息
|
||||
const [topupInfo, setTopupInfo] = useState({
|
||||
amount_options: [],
|
||||
discount: {}
|
||||
discount: {},
|
||||
});
|
||||
|
||||
const topUp = async () => {
|
||||
@@ -262,9 +262,9 @@ const TopUp = () => {
|
||||
if (success) {
|
||||
setTopupInfo({
|
||||
amount_options: data.amount_options || [],
|
||||
discount: data.discount || {}
|
||||
discount: data.discount || {},
|
||||
});
|
||||
|
||||
|
||||
// 处理支付方式
|
||||
let payMethods = data.pay_methods || [];
|
||||
try {
|
||||
@@ -280,10 +280,15 @@ const TopUp = () => {
|
||||
payMethods = payMethods.map((method) => {
|
||||
// 规范化最小充值数
|
||||
const normalizedMinTopup = Number(method.min_topup);
|
||||
method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
|
||||
method.min_topup = Number.isFinite(normalizedMinTopup)
|
||||
? normalizedMinTopup
|
||||
: 0;
|
||||
|
||||
// Stripe 的最小充值从后端字段回填
|
||||
if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
|
||||
if (
|
||||
method.type === 'stripe' &&
|
||||
(!method.min_topup || method.min_topup <= 0)
|
||||
) {
|
||||
const stripeMin = Number(data.stripe_min_topup);
|
||||
if (Number.isFinite(stripeMin)) {
|
||||
method.min_topup = stripeMin;
|
||||
@@ -313,7 +318,11 @@ const TopUp = () => {
|
||||
setPayMethods(payMethods);
|
||||
const enableStripeTopUp = data.enable_stripe_topup || false;
|
||||
const enableOnlineTopUp = data.enable_online_topup || false;
|
||||
const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
|
||||
const minTopUpValue = enableOnlineTopUp
|
||||
? data.min_topup
|
||||
: enableStripeTopUp
|
||||
? data.stripe_min_topup
|
||||
: 1;
|
||||
setEnableOnlineTopUp(enableOnlineTopUp);
|
||||
setEnableStripeTopUp(enableStripeTopUp);
|
||||
setMinTopUp(minTopUpValue);
|
||||
@@ -330,12 +339,12 @@ const TopUp = () => {
|
||||
console.log('解析支付方式失败:', e);
|
||||
setPayMethods([]);
|
||||
}
|
||||
|
||||
|
||||
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
|
||||
if (data.amount_options && data.amount_options.length > 0) {
|
||||
const customPresets = data.amount_options.map(amount => ({
|
||||
const customPresets = data.amount_options.map((amount) => ({
|
||||
value: amount,
|
||||
discount: data.discount[amount] || 1.0
|
||||
discount: data.discount[amount] || 1.0,
|
||||
}));
|
||||
setPresetAmounts(customPresets);
|
||||
}
|
||||
@@ -483,7 +492,7 @@ const TopUp = () => {
|
||||
const selectPresetAmount = (preset) => {
|
||||
setTopUpCount(preset.value);
|
||||
setSelectedPreset(preset.value);
|
||||
|
||||
|
||||
// 计算实际支付金额,考虑折扣
|
||||
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
|
||||
const discountedAmount = preset.value * priceRatio * discount;
|
||||
|
||||
@@ -40,9 +40,10 @@ const PaymentConfirmModal = ({
|
||||
amountNumber,
|
||||
discountRate,
|
||||
}) => {
|
||||
const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
||||
const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
|
||||
const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
|
||||
const hasDiscount =
|
||||
discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
||||
const originalAmount = hasDiscount ? amountNumber / discountRate : 0;
|
||||
const discountAmount = hasDiscount ? originalAmount - amountNumber : 0;
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
|
||||
@@ -24,26 +24,26 @@ export const DATE_RANGE_PRESETS = [
|
||||
{
|
||||
text: '今天',
|
||||
start: () => dayjs().startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
end: () => dayjs().endOf('day').toDate(),
|
||||
},
|
||||
{
|
||||
text: '近 7 天',
|
||||
start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
end: () => dayjs().endOf('day').toDate(),
|
||||
},
|
||||
{
|
||||
text: '本周',
|
||||
start: () => dayjs().startOf('week').toDate(),
|
||||
end: () => dayjs().endOf('week').toDate()
|
||||
end: () => dayjs().endOf('week').toDate(),
|
||||
},
|
||||
{
|
||||
text: '近 30 天',
|
||||
start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
end: () => dayjs().endOf('day').toDate(),
|
||||
},
|
||||
{
|
||||
text: '本月',
|
||||
start: () => dayjs().startOf('month').toDate(),
|
||||
end: () => dayjs().endOf('month').toDate()
|
||||
end: () => dayjs().endOf('month').toDate(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -131,13 +131,11 @@ export const buildApiPayload = (
|
||||
seed: 'seed',
|
||||
};
|
||||
|
||||
|
||||
Object.entries(parameterMappings).forEach(([key, param]) => {
|
||||
const enabled = parameterEnabled[key];
|
||||
const value = inputs[param];
|
||||
const hasValue = value !== undefined && value !== null;
|
||||
|
||||
|
||||
if (enabled && hasValue) {
|
||||
payload[param] = value;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ export function setStatusData(data) {
|
||||
localStorage.setItem('logo', data.logo);
|
||||
localStorage.setItem('footer_html', data.footer_html);
|
||||
localStorage.setItem('quota_per_unit', data.quota_per_unit);
|
||||
// 兼容:保留旧字段,同时写入新的额度展示类型
|
||||
localStorage.setItem('display_in_currency', data.display_in_currency);
|
||||
localStorage.setItem('quota_display_type', data.quota_display_type || 'USD');
|
||||
localStorage.setItem('enable_drawing', data.enable_drawing);
|
||||
localStorage.setItem('enable_task', data.enable_task);
|
||||
localStorage.setItem('enable_data_export', data.enable_data_export);
|
||||
|
||||
@@ -832,12 +832,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
|
||||
if (typeof num !== 'number' || isNaN(num)) {
|
||||
return 0;
|
||||
}
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
num = num.toFixed(digits);
|
||||
if (displayInCurrency) {
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
return '¥' + num;
|
||||
} else if (quotaDisplayType === 'USD') {
|
||||
return '$' + num;
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbol = '¤';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || symbol;
|
||||
}
|
||||
} catch (e) {}
|
||||
return symbol + num;
|
||||
} else {
|
||||
return num;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
export function renderNumberWithPoint(num) {
|
||||
@@ -889,33 +902,111 @@ export function getQuotaWithUnit(quota, digits = 6) {
|
||||
}
|
||||
|
||||
export function renderQuotaWithAmount(amount) {
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
return '$' + amount;
|
||||
} else {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
if (quotaDisplayType === 'TOKENS') {
|
||||
return renderNumber(renderUnitWithQuota(amount));
|
||||
}
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
return '¥' + amount;
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbol = '¤';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || symbol;
|
||||
}
|
||||
} catch (e) {}
|
||||
return symbol + amount;
|
||||
}
|
||||
return '$' + amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前货币配置信息
|
||||
* @returns {Object} - { symbol, rate, type }
|
||||
*/
|
||||
export function getCurrencyConfig() {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
const statusStr = localStorage.getItem('status');
|
||||
|
||||
let symbol = '$';
|
||||
let rate = 1;
|
||||
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
symbol = '¥';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
rate = s?.usd_exchange_rate || 7;
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || '¤';
|
||||
rate = s?.custom_currency_exchange_rate || 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return { symbol, rate, type: quotaDisplayType };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将美元金额转换为当前选择的货币
|
||||
* @param {number} usdAmount - 美元金额
|
||||
* @param {number} digits - 小数位数
|
||||
* @returns {string} - 格式化后的货币字符串
|
||||
*/
|
||||
export function convertUSDToCurrency(usdAmount, digits = 2) {
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const convertedAmount = usdAmount * rate;
|
||||
return symbol + convertedAmount.toFixed(digits);
|
||||
}
|
||||
|
||||
export function renderQuota(quota, digits = 2) {
|
||||
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
quotaPerUnit = parseFloat(quotaPerUnit);
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
const result = quota / quotaPerUnit;
|
||||
const fixedResult = result.toFixed(digits);
|
||||
|
||||
// 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
|
||||
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
|
||||
const minValue = Math.pow(10, -digits);
|
||||
return '$' + minValue.toFixed(digits);
|
||||
}
|
||||
|
||||
return '$' + fixedResult;
|
||||
if (quotaDisplayType === 'TOKENS') {
|
||||
return renderNumber(quota);
|
||||
}
|
||||
return renderNumber(quota);
|
||||
const resultUSD = quota / quotaPerUnit;
|
||||
let symbol = '$';
|
||||
let value = resultUSD;
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let usdRate = 1;
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
usdRate = s?.usd_exchange_rate || 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
value = resultUSD * usdRate;
|
||||
symbol = '¥';
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbolCustom = '¤';
|
||||
let rate = 1;
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbolCustom = s?.custom_currency_symbol || symbolCustom;
|
||||
rate = s?.custom_currency_exchange_rate || rate;
|
||||
}
|
||||
} catch (e) {}
|
||||
value = resultUSD * rate;
|
||||
symbol = symbolCustom;
|
||||
}
|
||||
const fixedResult = value.toFixed(digits);
|
||||
if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) {
|
||||
const minValue = Math.pow(10, -digits);
|
||||
return symbol + minValue.toFixed(digits);
|
||||
}
|
||||
return symbol + fixedResult;
|
||||
}
|
||||
|
||||
function isValidGroupRatio(ratio) {
|
||||
@@ -1037,14 +1128,20 @@ export function renderModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
const displayPrice = (modelPrice * rate).toFixed(6);
|
||||
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: displayPrice,
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: displayTotal,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
@@ -1074,25 +1171,27 @@ export function renderModelPrice(
|
||||
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
||||
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
||||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
|
||||
(imageGenerationCallPrice * groupRatio);
|
||||
imageGenerationCallPrice * groupRatio;
|
||||
|
||||
return (
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
|
||||
price: inputRatioPrice,
|
||||
{i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
audioPrice: audioInputSeperatePrice
|
||||
? `,音频 $${audioInputPrice} / 1M tokens`
|
||||
? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
|
||||
: '',
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'输出价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
'输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
completionRatio: completionRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1100,10 +1199,11 @@ export function renderModelPrice(
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * cacheRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1112,11 +1212,12 @@ export function renderModelPrice(
|
||||
{image && imageOutputTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
|
||||
'图片输入价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (图片倍率: {{imageRatio}})',
|
||||
{
|
||||
price: imageRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (imageRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
total: imageRatioPrice * groupRatio,
|
||||
total: (imageRatioPrice * groupRatio * rate).toFixed(6),
|
||||
imageRatio: imageRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1124,22 +1225,25 @@ export function renderModelPrice(
|
||||
)}
|
||||
{webSearch && webSearchCallCount > 0 && (
|
||||
<p>
|
||||
{i18next.t('Web搜索价格:${{price}} / 1K 次', {
|
||||
price: webSearchPrice,
|
||||
{i18next.t('Web搜索价格:{{symbol}}{{price}} / 1K 次', {
|
||||
symbol: symbol,
|
||||
price: (webSearchPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{fileSearch && fileSearchCallCount > 0 && (
|
||||
<p>
|
||||
{i18next.t('文件搜索价格:${{price}} / 1K 次', {
|
||||
price: fileSearchPrice,
|
||||
{i18next.t('文件搜索价格:{{symbol}}{{price}} / 1K 次', {
|
||||
symbol: symbol,
|
||||
price: (fileSearchPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{imageGenerationCall && imageGenerationCallPrice > 0 && (
|
||||
<p>
|
||||
{i18next.t('图片生成调用:${{price}} / 1次', {
|
||||
price: imageGenerationCallPrice,
|
||||
{i18next.t('图片生成调用:{{symbol}}{{price}} / 1次', {
|
||||
symbol: symbol,
|
||||
price: (imageGenerationCallPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
@@ -1149,50 +1253,55 @@ export function renderModelPrice(
|
||||
let inputDesc = '';
|
||||
if (image && imageOutputTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
|
||||
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}',
|
||||
{
|
||||
nonImageInput: inputTokens - imageOutputTokens,
|
||||
imageInput: imageOutputTokens,
|
||||
imageRatio: imageRatio,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else if (cacheTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
|
||||
'(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}',
|
||||
{
|
||||
nonCacheInput: inputTokens - cacheTokens,
|
||||
cacheInput: cacheTokens,
|
||||
price: inputRatioPrice,
|
||||
cachePrice: cacheRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
cachePrice: (cacheRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
|
||||
'(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}',
|
||||
{
|
||||
nonAudioInput: inputTokens - audioInputTokens,
|
||||
audioInput: audioInputTokens,
|
||||
price: inputRatioPrice,
|
||||
audioPrice: audioInputPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
audioPrice: (audioInputPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{input}} tokens / 1M tokens * ${{price}}',
|
||||
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建输出部分描述
|
||||
const outputDesc = i18next.t(
|
||||
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
|
||||
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1202,10 +1311,11 @@ export function renderModelPrice(
|
||||
const extraServices = [
|
||||
webSearch && webSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
price: webSearchPrice,
|
||||
symbol: symbol,
|
||||
price: (webSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1213,10 +1323,11 @@ export function renderModelPrice(
|
||||
: '',
|
||||
fileSearch && fileSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
price: fileSearchPrice,
|
||||
symbol: symbol,
|
||||
price: (fileSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1224,9 +1335,10 @@ export function renderModelPrice(
|
||||
: '',
|
||||
imageGenerationCall && imageGenerationCallPrice > 0
|
||||
? i18next.t(
|
||||
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||
' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
price: imageGenerationCallPrice,
|
||||
symbol: symbol,
|
||||
price: (imageGenerationCallPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1235,12 +1347,13 @@ export function renderModelPrice(
|
||||
].join('');
|
||||
|
||||
return i18next.t(
|
||||
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
|
||||
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}',
|
||||
{
|
||||
inputDesc,
|
||||
outputDesc,
|
||||
extraServices,
|
||||
total: price.toFixed(6),
|
||||
symbol: symbol,
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
})()}
|
||||
@@ -1271,10 +1384,14 @@ export function renderLogContent(
|
||||
label: ratioLabel,
|
||||
useUserGroupRatio: useUserGroupRatio,
|
||||
} = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||
price: modelPrice,
|
||||
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio,
|
||||
});
|
||||
@@ -1367,14 +1484,19 @@ export function renderAudioModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
// 1 ratio = $0.002 / 1K tokens
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: (modelPrice * groupRatio * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
@@ -1409,16 +1531,18 @@ export function renderAudioModelPrice(
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('提示价格:${{price}} / 1M tokens', {
|
||||
price: inputRatioPrice,
|
||||
{i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'补全价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
'补全价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
completionRatio: completionRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1426,10 +1550,11 @@ export function renderAudioModelPrice(
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * cacheRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1437,20 +1562,22 @@ export function renderAudioModelPrice(
|
||||
)}
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频提示价格:${{price}} * {{audioRatio}} = ${{total}} / 1M tokens (音频倍率: {{audioRatio}})',
|
||||
'音频提示价格:{{symbol}}{{price}} * {{audioRatio}} = {{symbol}}{{total}} / 1M tokens (音频倍率: {{audioRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * audioRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * audioRatio * rate).toFixed(6),
|
||||
audioRatio: audioRatio,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频补全价格:${{price}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
|
||||
'音频补全价格:{{symbol}}{{price}} * {{audioRatio}} * {{audioCompRatio}} = {{symbol}}{{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * audioRatio * audioCompletionRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * audioRatio * audioCompletionRatio * rate).toFixed(6),
|
||||
audioRatio: audioRatio,
|
||||
audioCompRatio: audioCompletionRatio,
|
||||
},
|
||||
@@ -1459,48 +1586,52 @@ export function renderAudioModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0
|
||||
? i18next.t(
|
||||
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
'文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
nonCacheInput: inputTokens - cacheTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cachePrice: inputRatioPrice * cacheRatio,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频提示 {{input}} tokens / 1M tokens * ${{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * ${{audioCompPrice}} = ${{total}}',
|
||||
'音频提示 {{input}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: audioInputTokens,
|
||||
completion: audioCompletionTokens,
|
||||
audioInputPrice: audioRatio * inputRatioPrice,
|
||||
symbol: symbol,
|
||||
audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(6),
|
||||
audioCompPrice:
|
||||
audioRatio * audioCompletionRatio * inputRatioPrice,
|
||||
total: audioPrice.toFixed(6),
|
||||
(audioRatio * audioCompletionRatio * inputRatioPrice * rate).toFixed(6),
|
||||
total: (audioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = ${{total}}',
|
||||
'总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
total: price.toFixed(6),
|
||||
textPrice: textPrice.toFixed(6),
|
||||
audioPrice: audioPrice.toFixed(6),
|
||||
symbol: symbol,
|
||||
total: (price * rate).toFixed(6),
|
||||
textPrice: (textPrice * rate).toFixed(6),
|
||||
audioPrice: (audioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
@@ -1512,9 +1643,8 @@ export function renderAudioModelPrice(
|
||||
}
|
||||
|
||||
export function renderQuotaWithPrompt(quota, digits) {
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
if (quotaDisplayType !== 'TOKENS') {
|
||||
return i18next.t('等价金额:') + renderQuota(quota, digits);
|
||||
}
|
||||
return '';
|
||||
@@ -1538,15 +1668,19 @@ export function renderClaudeModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: (modelPrice * groupRatio * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
@@ -1575,28 +1709,31 @@ export function renderClaudeModelPrice(
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('提示价格:${{price}} / 1M tokens', {
|
||||
price: inputRatioPrice,
|
||||
{i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'补全价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens',
|
||||
'补全价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: completionRatio,
|
||||
total: completionRatioPrice,
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: cacheRatio,
|
||||
total: cacheRatioPrice,
|
||||
total: (cacheRatioPrice * rate).toFixed(2),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1605,11 +1742,12 @@ export function renderClaudeModelPrice(
|
||||
{cacheCreationTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存创建价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
|
||||
'缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: cacheCreationRatio,
|
||||
total: cacheCreationRatioPrice,
|
||||
total: (cacheCreationRatioPrice * rate).toFixed(6),
|
||||
cacheCreationRatio: cacheCreationRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1619,33 +1757,35 @@ export function renderClaudeModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0 || cacheCreationTokens > 0
|
||||
? i18next.t(
|
||||
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
'提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
nonCacheInput: nonCachedTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cacheRatio: cacheRatio,
|
||||
cacheCreationInput: cacheCreationTokens,
|
||||
cacheCreationRatio: cacheCreationRatio,
|
||||
cachePrice: cacheRatioPrice,
|
||||
cacheCreationPrice: cacheCreationRatioPrice,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
cachePrice: (cacheRatioPrice * rate).toFixed(2),
|
||||
cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
@@ -1670,10 +1810,14 @@ export function renderClaudeLogContent(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||
price: modelPrice,
|
||||
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio: groupRatio,
|
||||
});
|
||||
|
||||
@@ -646,9 +646,25 @@ export const calculateModelPrice = ({
|
||||
const numCompletion =
|
||||
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
|
||||
let symbol = '$';
|
||||
if (currency === 'CNY') {
|
||||
symbol = '¥';
|
||||
} else if (currency === 'CUSTOM') {
|
||||
try {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || '¤';
|
||||
} else {
|
||||
symbol = '¤';
|
||||
}
|
||||
} catch (e) {
|
||||
symbol = '¤';
|
||||
}
|
||||
}
|
||||
return {
|
||||
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
|
||||
inputPrice: `${symbol}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
|
||||
unitLabel,
|
||||
isPerToken: true,
|
||||
usedGroup,
|
||||
|
||||
@@ -25,9 +25,13 @@ import {
|
||||
showInfo,
|
||||
showSuccess,
|
||||
loadChannelModels,
|
||||
copy
|
||||
copy,
|
||||
} from '../../helpers';
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants';
|
||||
import {
|
||||
CHANNEL_OPTIONS,
|
||||
ITEMS_PER_PAGE,
|
||||
MODEL_TABLE_PAGE_SIZE,
|
||||
} from '../../constants';
|
||||
import { useIsMobile } from '../common/useIsMobile';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
@@ -64,7 +68,7 @@ export const useChannelsData = () => {
|
||||
|
||||
// Status filter
|
||||
const [statusFilter, setStatusFilter] = useState(
|
||||
localStorage.getItem('channel-status-filter') || 'all'
|
||||
localStorage.getItem('channel-status-filter') || 'all',
|
||||
);
|
||||
|
||||
// Type tabs states
|
||||
@@ -80,8 +84,8 @@ export const useChannelsData = () => {
|
||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [modelTablePage, setModelTablePage] = useState(1);
|
||||
const [selectedEndpointType, setSelectedEndpointType] = useState('');
|
||||
|
||||
const [selectedEndpointType, setSelectedEndpointType] = useState('');
|
||||
|
||||
// 使用 ref 来避免闭包问题,类似旧版实现
|
||||
const shouldStopBatchTestingRef = useRef(false);
|
||||
|
||||
@@ -117,9 +121,12 @@ export const useChannelsData = () => {
|
||||
// Initialize from localStorage
|
||||
useEffect(() => {
|
||||
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
||||
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||
const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
|
||||
const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
|
||||
const localPageSize =
|
||||
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||
const localEnableTagMode =
|
||||
localStorage.getItem('enable-tag-mode') === 'true';
|
||||
const localEnableBatchDelete =
|
||||
localStorage.getItem('enable-batch-delete') === 'true';
|
||||
|
||||
setIdSort(localIdSort);
|
||||
setPageSize(localPageSize);
|
||||
@@ -177,7 +184,10 @@ export const useChannelsData = () => {
|
||||
// Save column preferences
|
||||
useEffect(() => {
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
|
||||
localStorage.setItem(
|
||||
'channels-table-columns',
|
||||
JSON.stringify(visibleColumns),
|
||||
);
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
@@ -291,14 +301,21 @@ export const useChannelsData = () => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
||||
setLoading(true);
|
||||
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
|
||||
await searchChannels(
|
||||
enableTagMode,
|
||||
typeKey,
|
||||
statusF,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reqId = ++requestCounter.current;
|
||||
setLoading(true);
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
||||
@@ -312,7 +329,10 @@ export const useChannelsData = () => {
|
||||
if (success) {
|
||||
const { items, total, type_counts } = data;
|
||||
if (type_counts) {
|
||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||
const sumAll = Object.values(type_counts).reduce(
|
||||
(acc, v) => acc + v,
|
||||
0,
|
||||
);
|
||||
setTypeCounts({ ...type_counts, all: sumAll });
|
||||
}
|
||||
setChannelFormat(items, enableTagMode);
|
||||
@@ -336,11 +356,18 @@ export const useChannelsData = () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
|
||||
await loadChannels(
|
||||
page,
|
||||
pageSz,
|
||||
sortFlag,
|
||||
enableTagMode,
|
||||
typeKey,
|
||||
statusF,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
||||
@@ -348,7 +375,10 @@ export const useChannelsData = () => {
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const { items = [], total = 0, type_counts = {} } = data;
|
||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||
const sumAll = Object.values(type_counts).reduce(
|
||||
(acc, v) => acc + v,
|
||||
0,
|
||||
);
|
||||
setTypeCounts({ ...type_counts, all: sumAll });
|
||||
setChannelFormat(items, enableTagMode);
|
||||
setChannelCount(total);
|
||||
@@ -367,7 +397,14 @@ export const useChannelsData = () => {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(page, pageSize, idSort, enableTagMode);
|
||||
} else {
|
||||
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||
await searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -453,9 +490,16 @@ export const useChannelsData = () => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
setActivePage(page);
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -471,7 +515,14 @@ export const useChannelsData = () => {
|
||||
showError(reason);
|
||||
});
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
1,
|
||||
size,
|
||||
idSort,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -502,7 +553,10 @@ export const useChannelsData = () => {
|
||||
showError(res?.data?.message || t('渠道复制失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
|
||||
showError(
|
||||
t('渠道复制失败: ') +
|
||||
(error?.response?.data?.message || error?.message || error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -541,7 +595,11 @@ export const useChannelsData = () => {
|
||||
data.priority = parseInt(data.priority);
|
||||
break;
|
||||
case 'weight':
|
||||
if (data.weight === undefined || data.weight < 0 || data.weight === '') {
|
||||
if (
|
||||
data.weight === undefined ||
|
||||
data.weight < 0 ||
|
||||
data.weight === ''
|
||||
) {
|
||||
showInfo('权重必须是非负整数!');
|
||||
return;
|
||||
}
|
||||
@@ -684,7 +742,11 @@ export const useChannelsData = () => {
|
||||
const res = await API.post(`/api/channel/fix`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
|
||||
showSuccess(
|
||||
t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
|
||||
.replace('${success}', data.success)
|
||||
.replace('${fails}', data.fails),
|
||||
);
|
||||
await refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
@@ -701,7 +763,7 @@ export const useChannelsData = () => {
|
||||
}
|
||||
|
||||
// 添加到正在测试的模型集合
|
||||
setTestingModels(prev => new Set([...prev, model]));
|
||||
setTestingModels((prev) => new Set([...prev, model]));
|
||||
|
||||
try {
|
||||
let url = `/api/channel/test/${record.id}?model=${model}`;
|
||||
@@ -718,14 +780,14 @@ export const useChannelsData = () => {
|
||||
const { success, message, time } = res.data;
|
||||
|
||||
// 更新测试结果
|
||||
setModelTestResults(prev => ({
|
||||
setModelTestResults((prev) => ({
|
||||
...prev,
|
||||
[testKey]: {
|
||||
success,
|
||||
message,
|
||||
time: time || 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
|
||||
if (success) {
|
||||
@@ -743,7 +805,9 @@ export const useChannelsData = () => {
|
||||
);
|
||||
} else {
|
||||
showInfo(
|
||||
t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。')
|
||||
t(
|
||||
'通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。',
|
||||
)
|
||||
.replace('${name}', record.name)
|
||||
.replace('${model}', model)
|
||||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||
@@ -755,19 +819,19 @@ export const useChannelsData = () => {
|
||||
} catch (error) {
|
||||
// 处理网络错误
|
||||
const testKey = `${record.id}-${model}`;
|
||||
setModelTestResults(prev => ({
|
||||
setModelTestResults((prev) => ({
|
||||
...prev,
|
||||
[testKey]: {
|
||||
success: false,
|
||||
message: error.message || t('网络错误'),
|
||||
time: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
||||
} finally {
|
||||
// 从正在测试的模型集合中移除
|
||||
setTestingModels(prev => {
|
||||
setTestingModels((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(model);
|
||||
return newSet;
|
||||
@@ -782,9 +846,11 @@ export const useChannelsData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const models = currentTestChannel.models.split(',').filter(model =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||
);
|
||||
const models = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
|
||||
if (models.length === 0) {
|
||||
showError(t('没有找到匹配的模型'));
|
||||
@@ -795,9 +861,9 @@ export const useChannelsData = () => {
|
||||
shouldStopBatchTestingRef.current = false; // 重置停止标志
|
||||
|
||||
// 清空该渠道之前的测试结果
|
||||
setModelTestResults(prev => {
|
||||
setModelTestResults((prev) => {
|
||||
const newResults = { ...prev };
|
||||
models.forEach(model => {
|
||||
models.forEach((model) => {
|
||||
const testKey = `${currentTestChannel.id}-${model}`;
|
||||
delete newResults[testKey];
|
||||
});
|
||||
@@ -805,7 +871,12 @@ export const useChannelsData = () => {
|
||||
});
|
||||
|
||||
try {
|
||||
showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
|
||||
showInfo(
|
||||
t('开始批量测试 ${count} 个模型,已清空上次结果...').replace(
|
||||
'${count}',
|
||||
models.length,
|
||||
),
|
||||
);
|
||||
|
||||
// 提高并发数量以加快测试速度,参考旧版的并发限制
|
||||
const concurrencyLimit = 5;
|
||||
@@ -819,13 +890,16 @@ export const useChannelsData = () => {
|
||||
}
|
||||
|
||||
const batch = models.slice(i, i + concurrencyLimit);
|
||||
showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
||||
.replace('${current}', i + 1)
|
||||
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
||||
.replace('${total}', models.length)
|
||||
showInfo(
|
||||
t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
||||
.replace('${current}', i + 1)
|
||||
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
||||
.replace('${total}', models.length),
|
||||
);
|
||||
|
||||
const batchPromises = batch.map(model => testChannel(currentTestChannel, model, selectedEndpointType));
|
||||
const batchPromises = batch.map((model) =>
|
||||
testChannel(currentTestChannel, model, selectedEndpointType),
|
||||
);
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
results.push(...batchResults);
|
||||
|
||||
@@ -837,20 +911,20 @@ export const useChannelsData = () => {
|
||||
|
||||
// 短暂延迟避免过于频繁的请求
|
||||
if (i + concurrencyLimit < models.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldStopBatchTestingRef.current) {
|
||||
// 等待一小段时间确保所有结果都已更新
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 使用当前状态重新计算结果统计
|
||||
setModelTestResults(currentResults => {
|
||||
setModelTestResults((currentResults) => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
models.forEach(model => {
|
||||
models.forEach((model) => {
|
||||
const testKey = `${currentTestChannel.id}-${model}`;
|
||||
const result = currentResults[testKey];
|
||||
if (result && result.success) {
|
||||
@@ -862,10 +936,11 @@ export const useChannelsData = () => {
|
||||
|
||||
// 显示完成消息
|
||||
setTimeout(() => {
|
||||
showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
||||
.replace('${success}', successCount)
|
||||
.replace('${fail}', failCount)
|
||||
.replace('${total}', models.length)
|
||||
showSuccess(
|
||||
t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
||||
.replace('${success}', successCount)
|
||||
.replace('${fail}', failCount)
|
||||
.replace('${total}', models.length),
|
||||
);
|
||||
}, 100);
|
||||
|
||||
@@ -1053,4 +1128,4 @@ export const useChannelsData = () => {
|
||||
setCompactMode,
|
||||
setActivePage,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -183,7 +183,10 @@ export const useSidebar = () => {
|
||||
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||
|
||||
return () => {
|
||||
sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||
sidebarEventTarget.removeEventListener(
|
||||
SIDEBAR_REFRESH_EVENT,
|
||||
handleRefresh,
|
||||
);
|
||||
};
|
||||
}, [adminConfig]);
|
||||
|
||||
|
||||
@@ -64,6 +64,29 @@ export const useModelPricingData = () => {
|
||||
() => statusState?.status?.usd_exchange_rate ?? priceRate,
|
||||
[statusState, priceRate],
|
||||
);
|
||||
const customExchangeRate = useMemo(
|
||||
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
|
||||
[statusState],
|
||||
);
|
||||
const customCurrencySymbol = useMemo(
|
||||
() => statusState?.status?.custom_currency_symbol ?? '¤',
|
||||
[statusState],
|
||||
);
|
||||
|
||||
// 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币
|
||||
const siteDisplayType = useMemo(
|
||||
() => statusState?.status?.quota_display_type || 'USD',
|
||||
[statusState],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
siteDisplayType === 'USD' ||
|
||||
siteDisplayType === 'CNY' ||
|
||||
siteDisplayType === 'CUSTOM'
|
||||
) {
|
||||
setCurrency(siteDisplayType);
|
||||
}
|
||||
}, [siteDisplayType]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
@@ -156,6 +179,8 @@ export const useModelPricingData = () => {
|
||||
|
||||
if (currency === 'CNY') {
|
||||
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
|
||||
} else if (currency === 'CUSTOM') {
|
||||
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
|
||||
}
|
||||
return `$${priceInUSD.toFixed(3)}`;
|
||||
};
|
||||
|
||||
@@ -759,7 +759,6 @@
|
||||
"获取当前设置失败": "Failed to get current settings",
|
||||
"设置已更新": "Settings updated",
|
||||
"更新设置失败": "Update settings failed",
|
||||
"确认解绑": "Confirm unbinding",
|
||||
"您确定要解绑WxPusher吗?": "Are you sure you want to unbind WxPusher?",
|
||||
"解绑失败": "Unbinding failed",
|
||||
"订阅事件": "Subscribe to events",
|
||||
@@ -1478,7 +1477,7 @@
|
||||
"相关项目": "Related Projects",
|
||||
"基于New API的项目": "Projects Based on New API",
|
||||
"版权所有": "All rights reserved",
|
||||
"设计与开发由": "Designed & Developed with love by",
|
||||
"设计与开发由": "Designed & Developed by",
|
||||
"演示站点": "Demo Site",
|
||||
"页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct",
|
||||
"您无权访问此页面,请联系管理员": "You do not have permission to access this page. Please contact the administrator.",
|
||||
@@ -1811,7 +1810,10 @@
|
||||
"自定义模型名称": "Custom model name",
|
||||
"启用全部密钥": "Enable all keys",
|
||||
"充值价格显示": "Recharge price",
|
||||
"美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)",
|
||||
"自定义货币": "Custom currency",
|
||||
"自定义货币符号": "Custom currency symbol",
|
||||
"例如 €, £, Rp, ₩, ₹...": "For example, €, £, Rp, ₩, ₹...",
|
||||
"站点额度展示类型及汇率": "Site quota display type and exchange rate",
|
||||
"美元汇率": "USD exchange rate",
|
||||
"隐藏操作项": "Hide actions",
|
||||
"显示操作项": "Show actions",
|
||||
@@ -2222,7 +2224,6 @@
|
||||
"留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Leave blank to auto-use server address, multiple Origins for multi-domain deployment",
|
||||
"输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com",
|
||||
"保存 Passkey 设置": "Save Passkey Settings",
|
||||
"黑名单": "Blacklist",
|
||||
"字段透传控制": "Field Pass-through Control",
|
||||
"允许 service_tier 透传": "Allow service_tier Pass-through",
|
||||
"禁用 store 透传": "Disable store Pass-through",
|
||||
@@ -2230,6 +2231,21 @@
|
||||
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges",
|
||||
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction",
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy",
|
||||
"支持变量:": "Supported variables:",
|
||||
"请求头覆盖": "Request header override",
|
||||
"旧格式模板": "Old format template",
|
||||
"新格式模板": "New format template",
|
||||
"系统提示词拼接": "System prompt append",
|
||||
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "If the user request contains a system prompt, this setting will be appended to the user's system prompt",
|
||||
"键为请求中的模型名称,值为要替换的模型名称": "Key is the model name in the request, value is the model name to replace",
|
||||
"仅影响本地判断,不修改返回到上游的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:": "Only affects local judgment, does not modify the status code returned to the upstream, for example, rewrite the 400 error of the claude channel to 500 (for retry). Please do not abuse this function, for example:",
|
||||
"密钥更新模式": "Key update mode",
|
||||
"请选择密钥更新模式": "Please select key update mode",
|
||||
"追加到现有密钥": "Append to existing key",
|
||||
"覆盖现有密钥": "Overwrite existing key",
|
||||
"追加模式:将新密钥添加到现有密钥列表末尾": "Append mode: add new keys to the end of the existing key list",
|
||||
"覆盖模式:将完全替换现有的所有密钥": "Overwrite mode: completely replace all existing keys",
|
||||
"轮询模式必须搭配Redis和内存缓存功能使用,否则性能将大幅降低,并且无法实现轮询功能": "Polling mode must be used with Redis and memory cache functions, otherwise the performance will be significantly reduced and the polling function will not be implemented",
|
||||
"common": {
|
||||
"changeLanguage": "Change Language"
|
||||
}
|
||||
|
||||
@@ -1806,7 +1806,10 @@
|
||||
"自定义模型名称": "Nom de modèle personnalisé",
|
||||
"启用全部密钥": "Activer toutes les clés",
|
||||
"充值价格显示": "Prix de recharge",
|
||||
"美元汇率(非充值汇率,仅用于定价页面换算)": "Taux de change USD (pas de taux de recharge, uniquement utilisé pour la conversion de la page de tarification)",
|
||||
"站点额度展示类型及汇率": "Type d'affichage du quota du site et taux de change",
|
||||
"自定义货币": "Devise personnalisée",
|
||||
"自定义货币符号": "Symbole de devise personnalisé",
|
||||
"例如 €, £, Rp, ₩, ₹...": "Par exemple, €, £, Rp, ₩, ₹...",
|
||||
"美元汇率": "Taux de change USD",
|
||||
"隐藏操作项": "Masquer les actions",
|
||||
"显示操作项": "Afficher les actions",
|
||||
@@ -2033,7 +2036,6 @@
|
||||
"查看渠道密钥": "Afficher la clé du canal",
|
||||
"渠道密钥信息": "Informations sur la clé du canal",
|
||||
"密钥获取成功": "Acquisition de la clé réussie",
|
||||
"模型补全倍率(仅对自定义模型有效)": "Ratio d'achèvement de modèle (uniquement efficace pour les modèles personnalisés)",
|
||||
"图片倍率": "Ratio d'image",
|
||||
"音频倍率": "Ratio audio",
|
||||
"音频补全倍率": "Ratio d'achèvement audio",
|
||||
@@ -2176,5 +2178,65 @@
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Le champ safety_identifier aide OpenAI à identifier les utilisateurs d'applications susceptibles de violer les politiques d'utilisation. Désactivé par défaut pour protéger la confidentialité des utilisateurs",
|
||||
"common": {
|
||||
"changeLanguage": "Changer de langue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Passkey 已解绑": "Passkey supprimé",
|
||||
"Passkey 已重置": "Le Passkey a été réinitialisé",
|
||||
"Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey est une méthode d'authentification sans mot de passe basée sur la norme WebAuthn, prenant en charge les empreintes digitales, la reconnaissance faciale, les clés matérielles et d'autres méthodes d'authentification",
|
||||
"Passkey 注册失败,请重试": "L'enregistrement du Passkey a échoué. Veuillez réessayer.",
|
||||
"Passkey 注册成功": "Enregistrement du Passkey réussi",
|
||||
"Passkey 登录": "Connexion avec Passkey",
|
||||
"cross-platform": "multiplateforme",
|
||||
"discouraged": "déconseillé",
|
||||
"platform": "plateforme",
|
||||
"preferred": "préféré",
|
||||
"required": "requis",
|
||||
"二步验证已重置": "L'authentification à deux facteurs a été réinitialisée",
|
||||
"仅用于开发环境,生产环境应使用 HTTPS": "Pour le développement uniquement, utilisez HTTPS en production",
|
||||
"使用 Passkey 实现免密且更安全的登录体验": "Utilisez Passkey pour une expérience de connexion sans mot de passe et plus sécurisée.",
|
||||
"使用 Passkey 认证": "S'authentifier avec Passkey",
|
||||
"例如:example.com": "ex: example.com",
|
||||
"保存 Passkey 设置": "Enregistrer les paramètres Passkey",
|
||||
"允许不安全的 Origin(HTTP)": "Autoriser une origine non sécurisée (HTTP)",
|
||||
"允许的 Origins": "Origines autorisées",
|
||||
"允许通过 Passkey 登录 & 认证": "Autoriser la connexion et l'authentification via Passkey",
|
||||
"删除密钥失败": "Échec de la suppression de la clé",
|
||||
"备份支持": "Prise en charge de la sauvegarde",
|
||||
"备份状态": "État de la sauvegarde",
|
||||
"安全验证级别": "Niveau de vérification de la sécurité",
|
||||
"密钥已删除": "La clé a été supprimée",
|
||||
"已取消 Passkey 注册": "Enregistrement du Passkey annulé",
|
||||
"已启用 Passkey,无需密码即可登录": "Passkey activé. Connexion sans mot de passe disponible.",
|
||||
"已备份": "Sauvegardé",
|
||||
"当前设备不支持 Passkey": "Passkey n'est pas pris en charge sur cet appareil",
|
||||
"推荐:用户可以选择是否使用指纹等验证": "Recommandé : les utilisateurs peuvent choisir d'utiliser ou non la vérification par empreinte digitale",
|
||||
"操作失败,请重试": "L'opération a échoué, veuillez réessayer",
|
||||
"支持备份": "Pris en charge",
|
||||
"是否要求指纹/面容等生物识别": "Exiger une reconnaissance biométrique par empreinte digitale/faciale",
|
||||
"最后使用时间": "Dernière utilisation",
|
||||
"服务显示名称": "Nom d'affichage du service",
|
||||
"未备份": "Non sauvegardé",
|
||||
"本设备:手机指纹/面容,外接:USB安全密钥": "Intégré : empreinte digitale/visage du téléphone, Externe : clé de sécurité USB",
|
||||
"此操作不可撤销,将永久删除该密钥": "Cette opération ne peut être annulée et la clé sera définitivement supprimée.",
|
||||
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "Cela désactivera la configuration actuelle de l'authentification à deux facteurs de l'utilisateur. Aucun code de vérification ne sera requis jusqu'à ce qu'il la réactive.",
|
||||
"此操作将解绑用户当前的 Passkey,下次登录需要重新注册。": "Cela détachera le Passkey actuel de l'utilisateur. Il devra se réenregistrer lors de sa prochaine connexion.",
|
||||
"注册 Passkey": "Enregistrer un Passkey",
|
||||
"用以支持基于 WebAuthn 的无密码登录注册": "Prise en charge de la connexion et de l'enregistrement sans mot de passe basés sur WebAuthn",
|
||||
"用户注册时看到的网站名称,比如'我的网站'": "Nom du site Web que les utilisateurs voient lors de l'inscription, par exemple 'Mon site Web'",
|
||||
"留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Laissez vide pour utiliser automatiquement l'adresse du serveur, plusieurs origines pour la prise en charge du déploiement multi-domaines",
|
||||
"留空自动使用当前域名": "Laissez vide pour utiliser automatiquement le domaine actuel",
|
||||
"目标用户:{{username}}": "Utilisateur cible : {{username}}",
|
||||
"确定要删除此密钥吗?": "Êtes-vous sûr de vouloir supprimer cette clé ?",
|
||||
"确认解绑 Passkey": "Confirmer la dissociation du Passkey",
|
||||
"确认重置 Passkey": "Confirmer la réinitialisation du Passkey",
|
||||
"确认重置两步验证": "Confirmer la réinitialisation de l'authentification à deux facteurs",
|
||||
"网站域名标识": "ID de domaine du site Web",
|
||||
"解绑 Passkey": "Supprimer le Passkey",
|
||||
"解绑后将无法使用 Passkey 登录,确定要继续吗?": "Après la dissociation, vous ne pourrez plus vous connecter avec Passkey. Êtes-vous sûr de vouloir continuer ?",
|
||||
"设备类型偏好": "Préférence de type d'appareil",
|
||||
"输入 Origin 后回车,如:https://example.com": "Saisissez l'origine et appuyez sur Entrée, ex : https://example.com",
|
||||
"选择支持的认证设备类型": "Choisissez les types d'appareils d'authentification pris en charge",
|
||||
"配置 Passkey": "Configurer Passkey",
|
||||
"重置 2FA": "Réinitialiser 2FA",
|
||||
"重置 Passkey": "Réinitialiser le Passkey",
|
||||
"默认使用系统名称": "Le nom du système est utilisé par défaut"
|
||||
}
|
||||
|
||||
@@ -17,8 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Modal,
|
||||
Select,
|
||||
InputGroup,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
@@ -35,10 +46,12 @@ export default function GeneralSettings(props) {
|
||||
const [inputs, setInputs] = useState({
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
'general_setting.custom_currency_symbol': '¤',
|
||||
'general_setting.custom_currency_exchange_rate': '',
|
||||
QuotaPerUnit: '',
|
||||
RetryTimes: '',
|
||||
USDExchangeRate: '',
|
||||
DisplayInCurrencyEnabled: false,
|
||||
DisplayTokenStatEnabled: false,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
@@ -88,6 +101,30 @@ export default function GeneralSettings(props) {
|
||||
});
|
||||
}
|
||||
|
||||
// 计算展示在输入框中的“1 USD = X <currency>”中的 X
|
||||
const combinedRate = useMemo(() => {
|
||||
const type = inputs['general_setting.quota_display_type'];
|
||||
if (type === 'USD') return '1';
|
||||
if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
|
||||
if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
|
||||
if (type === 'CUSTOM')
|
||||
return String(
|
||||
inputs['general_setting.custom_currency_exchange_rate'] || '',
|
||||
);
|
||||
return '';
|
||||
}, [inputs]);
|
||||
|
||||
const onCombinedRateChange = (val) => {
|
||||
const type = inputs['general_setting.quota_display_type'];
|
||||
if (type === 'CNY') {
|
||||
handleFieldChange('USDExchangeRate')(val);
|
||||
} else if (type === 'TOKENS') {
|
||||
handleFieldChange('QuotaPerUnit')(val);
|
||||
} else if (type === 'CUSTOM') {
|
||||
handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
@@ -95,6 +132,28 @@ export default function GeneralSettings(props) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
// 若旧字段存在且新字段缺失,则做一次兜底映射
|
||||
if (
|
||||
currentInputs['general_setting.quota_display_type'] === undefined &&
|
||||
props.options?.DisplayInCurrencyEnabled !== undefined
|
||||
) {
|
||||
currentInputs['general_setting.quota_display_type'] = props.options
|
||||
.DisplayInCurrencyEnabled
|
||||
? 'USD'
|
||||
: 'TOKENS';
|
||||
}
|
||||
// 回填自定义货币相关字段(如果后端已存在)
|
||||
if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
|
||||
currentInputs['general_setting.custom_currency_symbol'] =
|
||||
props.options['general_setting.custom_currency_symbol'];
|
||||
}
|
||||
if (
|
||||
props.options['general_setting.custom_currency_exchange_rate'] !==
|
||||
undefined
|
||||
) {
|
||||
currentInputs['general_setting.custom_currency_exchange_rate'] =
|
||||
props.options['general_setting.custom_currency_exchange_rate'];
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
@@ -130,29 +189,7 @@ export default function GeneralSettings(props) {
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'QuotaPerUnit'}
|
||||
label={t('单位美元额度')}
|
||||
initValue={''}
|
||||
placeholder={t('一单位货币能兑换的额度')}
|
||||
onChange={handleFieldChange('QuotaPerUnit')}
|
||||
showClear
|
||||
onClick={() => setShowQuotaWarning(true)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'USDExchangeRate'}
|
||||
label={t('美元汇率(非充值汇率,仅用于定价页面换算)')}
|
||||
initValue={''}
|
||||
placeholder={t('美元汇率')}
|
||||
onChange={handleFieldChange('USDExchangeRate')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'RetryTimes'}
|
||||
@@ -163,18 +200,51 @@ export default function GeneralSettings(props) {
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DisplayInCurrencyEnabled'}
|
||||
label={t('以货币形式显示额度')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange('DisplayInCurrencyEnabled')}
|
||||
<Form.Slot label={t('站点额度展示类型及汇率')}>
|
||||
<InputGroup style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={'1 USD = '}
|
||||
style={{ width: '50%' }}
|
||||
value={combinedRate}
|
||||
onChange={onCombinedRateChange}
|
||||
disabled={
|
||||
inputs['general_setting.quota_display_type'] === 'USD'
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
value={inputs['general_setting.quota_display_type']}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.quota_display_type',
|
||||
)}
|
||||
>
|
||||
<Select.Option value='USD'>USD ($)</Select.Option>
|
||||
<Select.Option value='CNY'>CNY (¥)</Select.Option>
|
||||
<Select.Option value='TOKENS'>Tokens</Select.Option>
|
||||
<Select.Option value='CUSTOM'>
|
||||
{t('自定义货币')}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</InputGroup>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'general_setting.custom_currency_symbol'}
|
||||
label={t('自定义货币符号')}
|
||||
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.custom_currency_symbol',
|
||||
)}
|
||||
showClear
|
||||
disabled={
|
||||
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DisplayTokenStatEnabled'}
|
||||
@@ -195,8 +265,6 @@ export default function GeneralSettings(props) {
|
||||
onChange={handleFieldChange('DefaultCollapseSidebar')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DemoSiteEnabled'}
|
||||
|
||||
@@ -128,7 +128,8 @@ export default function SettingsMonitoring(props) {
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'monitor_setting.auto_test_channel_minutes': parseInt(value),
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
parseInt(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') {
|
||||
if (
|
||||
originInputs['AmountOptions'] !== inputs.AmountOptions &&
|
||||
inputs.AmountOptions.trim() !== ''
|
||||
) {
|
||||
if (!verifyJSON(inputs.AmountOptions)) {
|
||||
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') {
|
||||
if (
|
||||
originInputs['AmountDiscount'] !== inputs.AmountDiscount &&
|
||||
inputs.AmountDiscount.trim() !== ''
|
||||
) {
|
||||
if (!verifyJSON(inputs.AmountDiscount)) {
|
||||
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
|
||||
return;
|
||||
@@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) {
|
||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||
}
|
||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
|
||||
options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions });
|
||||
options.push({
|
||||
key: 'payment_setting.amount_options',
|
||||
value: inputs.AmountOptions,
|
||||
});
|
||||
}
|
||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
|
||||
options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount });
|
||||
options.push({
|
||||
key: 'payment_setting.amount_discount',
|
||||
value: inputs.AmountDiscount,
|
||||
});
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
@@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) {
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
autosize
|
||||
/>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -282,13 +294,17 @@ export default function SettingsPaymentGateway(props) {
|
||||
<Form.TextArea
|
||||
field='AmountOptions'
|
||||
label={t('自定义充值数量选项')}
|
||||
placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
|
||||
)}
|
||||
autosize
|
||||
extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
|
||||
extraText={t(
|
||||
'设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -297,13 +313,17 @@ export default function SettingsPaymentGateway(props) {
|
||||
<Form.TextArea
|
||||
field='AmountDiscount'
|
||||
label={t('充值金额折扣配置')}
|
||||
placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||
)}
|
||||
autosize
|
||||
extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
||||
extraText={t(
|
||||
'设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
|
||||
@@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) {
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('图片输入倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}')}
|
||||
extraText={t(
|
||||
'图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费',
|
||||
)}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}',
|
||||
)}
|
||||
field={'ImageRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
@@ -238,9 +242,7 @@ export default function ModelRatioSettings(props) {
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ImageRatio: value })
|
||||
}
|
||||
onChange={(value) => setInputs({ ...inputs, ImageRatio: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) {
|
||||
<Form.TextArea
|
||||
label={t('音频倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}',
|
||||
)}
|
||||
field={'AudioRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
@@ -260,9 +264,7 @@ export default function ModelRatioSettings(props) {
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AudioRatio: value })
|
||||
}
|
||||
onChange={(value) => setInputs({ ...inputs, AudioRatio: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) {
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('音频补全倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('音频输出补全相关的倍率设置,键为模型名称,值为倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}')}
|
||||
extraText={t(
|
||||
'音频输出补全相关的倍率设置,键为模型名称,值为倍率',
|
||||
)}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}',
|
||||
)}
|
||||
field={'AudioCompletionRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
|
||||
Reference in New Issue
Block a user