Compare commits

..

70 Commits

Author SHA1 Message Date
coderabbitai[bot]
72c456f39c 📝 Add docstrings to main
Docstrings generation was requested by @MomentDerek.

* https://github.com/QuantumNous/new-api/pull/1967#issuecomment-3367913086

The following files were modified:

* `relay/channel/gemini/relay-gemini.go`
2025-10-04 05:17:32 +00:00
CaIon
7437b671ef 💱 feat: implement currency configuration helper and update currency display logic in RechargeCard and render functions 2025-10-03 21:49:24 +08:00
CaIon
55d19df029 chore: remove outdated instructions from pull request template 2025-10-03 21:40:07 +08:00
CaIon
731e9f4ca9 💱 feat(RechargeCard): enhance currency display logic for top-up amounts based on user settings 2025-10-03 21:39:28 +08:00
Calcium-Ion
cc6fcebda1 Merge pull request #1957 from seefs001/pr/custom-currency-1923
💱 feat(settings): introduce site-wide quota display type
2025-10-03 21:17:16 +08:00
CaIon
72a12e3747 chore: update README files 2025-10-03 20:28:26 +08:00
CaIon
0adfcf9d27 chore: update README files 2025-10-03 20:27:34 +08:00
CaIon
51d71a6e1a feat: add Spanish feature request template to GitHub issue tracker for improved feature proposal submissions 2025-10-03 14:45:39 +08:00
CaIon
c9abe1d769 feat: add English feature request template to GitHub issue tracker for enhanced feature proposal submissions 2025-10-03 14:01:16 +08:00
CaIon
a8bfa7ad29 feat: add English bug report template to GitHub issue tracker for improved issue reporting #1960 2025-10-03 13:59:27 +08:00
Seefs
f24feed775 Merge pull request #1963 from feitianbubu/pr/refactor-channel-test
refactor: simplify unsupported test channel types
2025-10-03 12:46:38 +08:00
CaIon
6b75bc0016 refactor(openai_image): replace json.Marshal with common.Marshal for improved serialization #1961 2025-10-03 12:44:33 +08:00
feitianbubu
937d931442 refactor: simplify unsupported test channel types 2025-10-03 12:41:39 +08:00
Seefs
5c6e6032ef Merge pull request #1959 from RedwindA/feat/silicon-fim
feat: Allow FIM chat requests without messages
2025-10-03 12:37:38 +08:00
CaIon
d5e01a3eab feat(gemini): add imageConfig field to GeminiChatRequest for flexible image configuration 2025-10-03 12:26:17 +08:00
RedwindA
69e1542fc9 feat: Allow FIM chat requests without messages 2025-10-03 02:27:02 +08:00
Seefs
3199e2e8cd Merge branch 'main-upstream' into pr/custom-currency-1923
# Conflicts:
#	web/src/components/settings/personal/cards/AccountManagement.jsx
#	web/src/components/table/channels/modals/EditChannelModal.jsx
#	web/src/hooks/channels/useChannelsData.jsx
#	web/src/hooks/common/useSidebar.js
#	web/src/i18n/locales/fr.json
#	web/src/pages/Setting/Operation/SettingsGeneral.jsx
2025-10-02 20:30:48 +08:00
CaIon
66d0764fc1 docs: update README files to include Japanese language support 2025-10-02 19:55:37 +08:00
CaIon
01bcbf09c6 feat(api): add header override processing with variable support 2025-10-02 19:29:57 +08:00
CaIon
0682a15971 Merge remote-tracking branch 'origin/main' 2025-10-02 19:01:13 +08:00
Calcium-Ion
19bbb7d7c7 Merge pull request #1890 from QuantumNous/pr/console-footer
 feat(layout): refine footer visibility logic to target CardPro component pages
2025-10-02 19:00:57 +08:00
CaIon
ace855ed36 refactor(footer): update footer links and localization text
- Removed the 'chatnio' link from the footer.
- Added new links for 'CoAI' and 'GPT-Load' in the footer.
- Updated the localization key for '基于New API的项目' to '友情链接' for better clarity.
- Adjusted the design of the footer to improve layout and visibility of the developer credit.
2025-10-02 19:00:07 +08:00
CaIon
36ed41ad7a Merge remote-tracking branch 'origin/pr/console-footer' into pr/console-footer
# Conflicts:
#	web/src/components/table/channels/modals/EditChannelModal.jsx
#	web/src/hooks/common/useSidebar.js
2025-10-02 18:46:16 +08:00
t0ng7u
df19a8de5d feat(layout): refine footer visibility logic to target CardPro component pages
- Replace blanket console route footer hiding with specific page targeting
- Only hide footer on pages that use CardPro component:
  * /console/channel (channels management)
  * /console/log (usage logs)
  * /console/redemption (redemption codes)
  * /console/user (user management)
  * /console/token (token management)
  * /console/midjourney (midjourney logs)
  * /console/task (task logs)
  * /console/models (model management)
  * /pricing (pricing page)
- Footer now displays on other console pages (dashboard, settings, topup, etc.)
- Improves UI consistency by showing footer where CardPro's internal pagination isn't used

This change ensures footer is only hidden when CardPro component provides its own
pagination/footer functionality, while preserving footer visibility on other pages
that benefit from the global footer navigation.
2025-10-02 18:45:37 +08:00
CaIon
0074085b13 Merge remote-tracking branch 'origin/main' 2025-10-02 18:43:32 +08:00
Calcium-Ion
f473d20a09 Merge pull request #1932 from seefs001/chore/upgrade-deps
chore: go version & sonic dep
2025-10-02 18:37:56 +08:00
Calcium-Ion
9061411ec7 Merge pull request #1940 from comeback01/french-translation-final
feat(i18n): add and update French translations
2025-10-02 18:37:34 +08:00
comeback01
6ee01d75a6 Merge branch 'main' into french-translation-final 2025-10-02 10:57:34 +02:00
CaIon
b0b275b236 chore(docker): add comment for compatibility with older Docker versions 2025-10-02 16:20:15 +08:00
CaIon
81a66be721 chore(docker): switch from MySQL to PostgreSQL in docker-compose configuration 2025-10-02 16:14:15 +08:00
CaIon
01469aa01c refactor(adaptor): extract common header operations into a separate function 2025-10-02 15:28:09 +08:00
Calcium-Ion
0769184b9b Merge pull request #1956 from QuantumNous/alpha
alpha -> main
2025-10-02 15:26:59 +08:00
Seefs
4137120d69 Merge pull request #1779 from feitianbubu/pr/fix-video-get-task
fix: get video task err when Content-Type=json
2025-10-02 14:43:18 +08:00
Seefs
3d1433dd70 Merge branch 'alpha' into pr/fix-video-get-task 2025-10-02 14:43:11 +08:00
Seefs
acbfc9d3b3 Merge pull request #1951 from feitianbubu/pr/add-doubao-video
增加豆包视频渠道
2025-10-02 14:41:41 +08:00
Seefs
4cdad47695 Merge pull request #1955 from seefs001/fix/megge-conflict
fix: merge conflict
2025-10-02 14:30:25 +08:00
Seefs
6a1de0ebdc fix: merge conflict 2025-10-02 14:28:58 +08:00
Seefs
92a4e88ceb Merge pull request #1844 from JoeyLearnsToCode/feat-channel-block-edit
feat: Add navigation buttons for channel edit form sections
2025-10-02 14:16:11 +08:00
Seefs
e9043590a9 Merge branch 'alpha' into feat-channel-block-edit 2025-10-02 14:16:01 +08:00
Seefs
9e6828653b Merge pull request #1947 from HynoR/feat/newedit
feat: Add visual editing mode for chat configurations
2025-10-02 14:11:49 +08:00
Seefs
dae661bb53 Merge pull request #1948 from RedwindA/feat/gotify
feat: Add Gotify Notification Channel for Quota Alerts
2025-10-02 14:11:20 +08:00
Seefs
649a5205c9 Merge pull request #1950 from seefs001/fix/missing-field
fix: missing field & field control
2025-10-02 14:10:11 +08:00
Seefs
26a563da54 fix: Return the original payload and nil error on Unmarshal or Marshal failures in RemoveDisabledFields 2025-10-02 13:57:49 +08:00
Seefs
c1492be131 Merge pull request #1954 from QuantumNous/main
main -> alpha
2025-10-02 13:50:18 +08:00
feitianbubu
7ca65a5e8e feat: add doubao video add log detail 2025-10-02 04:00:50 +08:00
feitianbubu
b244a06ca1 feat: add doubao video use quota by total token 2025-10-02 04:00:46 +08:00
feitianbubu
c320410c84 feat: add doubao video generate 2025-10-02 04:00:43 +08:00
Seefs
2938246f2e Merge pull request #1949 from RedwindA/fix/oai-responses-webSearch-panic
fix(openai): add nil checks for web_search streaming to prevent panic
2025-10-02 00:36:25 +08:00
Seefs
0e9ad4a15f fix: missing field & field control 2025-10-02 00:14:35 +08:00
RedwindA
2200bb9166 fix(openai): add nil checks for web_search streaming to prevent panic 2025-10-01 22:19:22 +08:00
RedwindA
d6db10b4bc feat: 添加 Bark 和 Gotify 通知的国际化支持 2025-10-01 19:36:19 +08:00
RedwindA
85ff8b1422 feat: add Gotify notification option for quota alerts 2025-10-01 19:15:00 +08:00
HynoR
1428338546 feat: Enhance SettingsChats edit interface 2025-10-01 18:40:02 +08:00
HynoR
ec76b0f5e2 feat: add visual editing mode for chat configurations 2025-10-01 18:22:32 +08:00
Seefs
96b172e93b Merge pull request #1945 from QuantumNous/gemini-robotics-er
feat: 支持 gemini-robotics-er-1.5-preview
2025-10-01 17:41:41 +08:00
creamlike1024
70263e96ab feat: 支持 gemini-robotics-er-1.5-preview 2025-10-01 17:33:54 +08:00
Seefs
15db5c0062 Merge pull request #1943 from RedwindA/fix/jina-embedding
fix(jina): remove encoding_format for jina embedding
2025-10-01 16:58:56 +08:00
RedwindA
f5a774f22c fix(jina): remove encoding_format for jina embedding 2025-10-01 02:06:30 +08:00
comeback01
0735b0c604 feat(i18n): add and update French translations 2025-09-30 17:07:45 +02:00
CaIon
0b91e45197 fix(adaptor): update relay mode handling to support relay format 2025-09-30 16:53:57 +08:00
CaIon
6bc3e62fd5 feat: add endpoint type selection to channel testing functionality 2025-09-30 16:52:14 +08:00
Seefs
c4e0fc1837 chore: go version & sonic dep 2025-09-30 14:38:20 +08:00
Seefs
aab82f22fa Merge pull request #1817 from wzxjohn/hotfix/relay_vertex_claude
fix(relay): wrong URL for claude model in GCP Vertex AI
2025-09-30 11:27:15 +08:00
t0ng7u
39a868faea 💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM)
Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe
configuration `general_setting.quota_display_type`, and wire it through the
backend and frontend.

Backend
- Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected
  registration via `config.GlobalConfig.Register("general_setting", ...)`.
  Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`.
- Expose `quota_display_type` in `/api/status` and keep legacy
  `display_in_currency` for backward compatibility.
- Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When
  CNY is selected, convert using `operation_setting.USDExchangeRate`.
- Controllers:
  - `billing`: compute subscription/usage amounts based on the selected type
    (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens).
  - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as
    token-count for TOKENS; adjust min topup and pay money accordingly.
  - `misc`: include `quota_display_type` in status payload.
- Compatibility: in `model/option.UpdateOption`, map updates to
  `DisplayInCurrencyEnabled` → `general_setting.quota_display_type`
  (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`.

Frontend
- Settings: replace the “display in currency” switch with a Select
  (`general_setting.quota_display_type`) offering USD / CNY / Tokens.
  Provide fallback mapping from legacy `DisplayInCurrencyEnabled`.
- Persist `quota_display_type` to localStorage (keep `display_in_currency`
  for legacy components).
- Rendering helpers: base all quota/price rendering on `quota_display_type`;
  use `usd_exchange_rate` for CNY symbol/values.
- Pricing page: default view currency follows site display type (USD/CNY),
  while TOKENS mode still allows per-view currency toggling when needed.

Notes
- No database migrations required.
- Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
JoeyLearnsToCode
3ed0ae83f1 Merge remote-tracking branch 'remotes/up-origin/main' into feat-channel-block-edit 2025-09-28 09:56:35 +08:00
t0ng7u
f354e5de23 feat(layout): refine footer visibility logic to target CardPro component pages
- Replace blanket console route footer hiding with specific page targeting
- Only hide footer on pages that use CardPro component:
  * /console/channel (channels management)
  * /console/log (usage logs)
  * /console/redemption (redemption codes)
  * /console/user (user management)
  * /console/token (token management)
  * /console/midjourney (midjourney logs)
  * /console/task (task logs)
  * /console/models (model management)
  * /pricing (pricing page)
- Footer now displays on other console pages (dashboard, settings, topup, etc.)
- Improves UI consistency by showing footer where CardPro's internal pagination isn't used

This change ensures footer is only hidden when CardPro component provides its own
pagination/footer functionality, while preserving footer visibility on other pages
that benefit from the global footer navigation.
2025-09-27 18:47:53 +08:00
JoeyLearnsToCode
ec9903e640 feat: jump between section on channel edit page 2025-09-19 18:11:47 +08:00
wzxjohn
8d92ce38ed fix(relay): wrong key param while enable sse 2025-09-19 11:22:03 +08:00
wzxjohn
f2e9fd7afb fix(relay): wrong URL for claude model in GCP Vertex AI 2025-09-16 17:18:32 +08:00
feitianbubu
465830945b fix: get video task err when Content-Type=json 2025-09-11 12:53:19 +08:00
100 changed files with 4071 additions and 1131 deletions

26
.github/ISSUE_TEMPLATE/bug_report_en.md vendored Normal file
View 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.

View 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**

View File

@@ -13,7 +13,3 @@
### PR 描述
**请在下方详细描述您的 PR包括目的、实现细节等。**
### **重要提示**
**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。**

View File

@@ -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">
![new-api](/web/public/logo.png)
@@ -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

View File

@@ -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">
![new-api](/web/public/logo.png)
@@ -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
View 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](/web/public/logo.png)
# 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">&nbsp;</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>&nbsp;</p>
## 📚 ドキュメント
詳細なドキュメントは公式Wikiをご覧ください[https://docs.newapi.pro/](https://docs.newapi.pro/)
AIが生成したDeepWikiにもアクセスできます
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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 CompletionsClaude 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の内容を取得できません
### デプロイ要件
- ローカルデータベースデフォルトSQLiteDockerデプロイの場合は`/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
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)

View File

@@ -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的项目

View File

@@ -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

View File

@@ -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"

View File

@@ -23,6 +23,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
}
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在

View File

@@ -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
)

View File

@@ -51,9 +51,9 @@ const (
ChannelTypeJimeng = 51
ChannelTypeVidu = 52
ChannelTypeSubmodel = 53
ChannelTypeDoubaoVideo = 54
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
var ChannelBaseURLs = []string{
@@ -111,4 +111,66 @@ var ChannelBaseURLs = []string{
"https://visual.volcengineapi.com", //51
"https://api.vidu.cn", //52
"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"
}

View File

@@ -9,6 +9,7 @@ const (
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
EndpointTypeEmbeddings EndpointType = "embeddings"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
//EndpointTypeSuno EndpointType = "suno-proxy"
//EndpointTypeKling EndpointType = "kling"

View File

@@ -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",

View File

@@ -28,6 +28,7 @@ import (
"time"
"github.com/bytedance/gopkg/util/gopool"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
)
@@ -38,42 +39,21 @@ type testResult struct {
newAPIError *types.NewAPIError
}
func testChannel(channel *model.Channel, testModel string) testResult {
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.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()
@@ -81,18 +61,26 @@ func testChannel(channel *model.Channel, testModel string) testResult {
requestPath := "/v1/chat/completions"
// 先判断是否为 Embedding 模
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
strings.Contains(testModel, "bge-") || // bge 系列模型
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
requestPath = "/v1/embeddings" // 修改请求路径
}
// 如果指定了端点类型,使用指定的端点类
if endpointType != "" {
if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok {
requestPath = endpointInfo.Path
}
} else {
// 如果没有指定端点类型,使用原有的自动检测逻辑
// 先判断是否为 Embedding 模型
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
strings.Contains(testModel, "bge-") || // bge 系列模型
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
requestPath = "/v1/embeddings" // 修改请求路径
}
// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
}
}
c.Request = &http.Request{
@@ -114,21 +102,6 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
// 重新检查模型类型并更新请求路径
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") ||
strings.Contains(testModel, "bge-") ||
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI {
requestPath = "/v1/embeddings"
c.Request.URL.Path = requestPath
}
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
c.Request.URL.Path = requestPath
}
cache, err := model.GetUserCache(1)
if err != nil {
return testResult{
@@ -153,17 +126,54 @@ func testChannel(channel *model.Channel, testModel string) testResult {
newAPIError: newAPIError,
}
}
request := buildTestRequest(testModel)
// Determine relay format based on request path
relayFormat := types.RelayFormatOpenAI
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
// Determine relay format based on endpoint type or request path
var relayFormat types.RelayFormat
if endpointType != "" {
// 根据指定的端点类型设置 relayFormat
switch constant.EndpointType(endpointType) {
case constant.EndpointTypeOpenAI:
relayFormat = types.RelayFormatOpenAI
case constant.EndpointTypeOpenAIResponse:
relayFormat = types.RelayFormatOpenAIResponses
case constant.EndpointTypeAnthropic:
relayFormat = types.RelayFormatClaude
case constant.EndpointTypeGemini:
relayFormat = types.RelayFormatGemini
case constant.EndpointTypeJinaRerank:
relayFormat = types.RelayFormatRerank
case constant.EndpointTypeImageGeneration:
relayFormat = types.RelayFormatOpenAIImage
case constant.EndpointTypeEmbeddings:
relayFormat = types.RelayFormatEmbedding
default:
relayFormat = types.RelayFormatOpenAI
}
} else {
// 根据请求路径自动检测
relayFormat = types.RelayFormatOpenAI
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
}
if c.Request.URL.Path == "/v1/messages" {
relayFormat = types.RelayFormatClaude
}
if strings.Contains(c.Request.URL.Path, "/v1beta/models") {
relayFormat = types.RelayFormatGemini
}
if c.Request.URL.Path == "/v1/rerank" || c.Request.URL.Path == "/rerank" {
relayFormat = types.RelayFormatRerank
}
if c.Request.URL.Path == "/v1/responses" {
relayFormat = types.RelayFormatOpenAIResponses
}
}
request := buildTestRequest(testModel, endpointType)
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
if err != nil {
@@ -186,7 +196,8 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
testModel = info.UpstreamModelName
request.Model = testModel
// 更新请求中的模型名称
request.SetModelName(testModel)
apiType, _ := common.ChannelType2APIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
@@ -216,33 +227,62 @@ func testChannel(channel *model.Channel, testModel string) testResult {
var convertedRequest any
// 根据 RelayMode 选择正确的转换函数
if info.RelayMode == relayconstant.RelayModeEmbeddings {
// 创建一个 EmbeddingRequest
embeddingRequest := dto.EmbeddingRequest{
Input: request.Input,
Model: request.Model,
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else if info.RelayMode == relayconstant.RelayModeImagesGenerations {
// 创建一个 ImageRequest
prompt := "cat"
if request.Prompt != nil {
if promptStr, ok := request.Prompt.(string); ok && promptStr != "" {
prompt = promptStr
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
// Embedding 请求 - request 已经是正确的类型
if embeddingReq, ok := request.(*dto.EmbeddingRequest); ok {
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, *embeddingReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid embedding request type"),
newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed),
}
}
imageRequest := dto.ImageRequest{
Prompt: prompt,
Model: request.Model,
N: uint(request.N),
Size: request.Size,
case relayconstant.RelayModeImagesGenerations:
// 图像生成请求 - request 已经是正确的类型
if imageReq, ok := request.(*dto.ImageRequest); ok {
convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid image request type"),
newAPIError: types.NewError(errors.New("invalid image request type"), types.ErrorCodeConvertRequestFailed),
}
}
case relayconstant.RelayModeRerank:
// Rerank 请求 - request 已经是正确的类型
if rerankReq, ok := request.(*dto.RerankRequest); ok {
convertedRequest, err = adaptor.ConvertRerankRequest(c, info.RelayMode, *rerankReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid rerank request type"),
newAPIError: types.NewError(errors.New("invalid rerank request type"), types.ErrorCodeConvertRequestFailed),
}
}
case relayconstant.RelayModeResponses:
// Response 请求 - request 已经是正确的类型
if responseReq, ok := request.(*dto.OpenAIResponsesRequest); ok {
convertedRequest, err = adaptor.ConvertOpenAIResponsesRequest(c, info, *responseReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid response request type"),
newAPIError: types.NewError(errors.New("invalid response request type"), types.ErrorCodeConvertRequestFailed),
}
}
default:
// Chat/Completion 等其他请求类型
if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok {
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid general request type"),
newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed),
}
}
// 调用专门用于图像生成的转换函数
convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
} else {
// 对其他所有请求类型(如 Chat保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
}
if err != nil {
@@ -345,22 +385,82 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest := &dto.GeneralOpenAIRequest{
Model: "", // this will be set later
Stream: false,
func buildTestRequest(model string, endpointType string) dto.Request {
// 根据端点类型构建不同的测试请求
if endpointType != "" {
switch constant.EndpointType(endpointType) {
case constant.EndpointTypeEmbeddings:
// 返回 EmbeddingRequest
return &dto.EmbeddingRequest{
Model: model,
Input: []any{"hello world"},
}
case constant.EndpointTypeImageGeneration:
// 返回 ImageRequest
return &dto.ImageRequest{
Model: model,
Prompt: "a cute cat",
N: 1,
Size: "1024x1024",
}
case constant.EndpointTypeJinaRerank:
// 返回 RerankRequest
return &dto.RerankRequest{
Model: model,
Query: "What is Deep Learning?",
Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."},
TopN: 2,
}
case constant.EndpointTypeOpenAIResponse:
// 返回 OpenAIResponsesRequest
return &dto.OpenAIResponsesRequest{
Model: model,
Input: json.RawMessage("\"hi\""),
}
case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI:
// 返回 GeneralOpenAIRequest
maxTokens := uint(10)
if constant.EndpointType(endpointType) == constant.EndpointTypeGemini {
maxTokens = 3000
}
return &dto.GeneralOpenAIRequest{
Model: model,
Stream: false,
Messages: []dto.Message{
{
Role: "user",
Content: "hi",
},
},
MaxTokens: maxTokens,
}
}
}
// 自动检测逻辑(保持原有行为)
// 先判断是否为 Embedding 模型
if strings.Contains(strings.ToLower(model), "embedding") || // 其他 embedding 模型
strings.HasPrefix(model, "m3e") || // m3e 系列模型
if strings.Contains(strings.ToLower(model), "embedding") ||
strings.HasPrefix(model, "m3e") ||
strings.Contains(model, "bge-") {
testRequest.Model = model
// Embedding 请求
testRequest.Input = []any{"hello world"} // 修改为any因为dto/openai_request.go 的ParseInput方法无法处理[]string类型
return testRequest
// 返回 EmbeddingRequest
return &dto.EmbeddingRequest{
Model: model,
Input: []any{"hello world"},
}
}
// 并非Embedding 模型
// Chat/Completion 请求 - 返回 GeneralOpenAIRequest
testRequest := &dto.GeneralOpenAIRequest{
Model: model,
Stream: false,
Messages: []dto.Message{
{
Role: "user",
Content: "hi",
},
},
}
if strings.HasPrefix(model, "o") {
testRequest.MaxCompletionTokens = 10
} else if strings.Contains(model, "thinking") {
@@ -373,12 +473,6 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest.MaxTokens = 10
}
testMessage := dto.Message{
Role: "user",
Content: "hi",
}
testRequest.Model = model
testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest
}
@@ -402,8 +496,9 @@ func TestChannel(c *gin.Context) {
// }
//}()
testModel := c.Query("model")
endpointType := c.Query("endpoint_type")
tik := time.Now()
result := testChannel(channel, testModel)
result := testChannel(channel, testModel, endpointType)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -429,7 +524,6 @@ func TestChannel(c *gin.Context) {
"message": "",
"time": consumedTime,
})
return
}
var testAllChannelsLock sync.Mutex
@@ -463,7 +557,7 @@ func testAllChannels(notify bool) error {
for _, channel := range channels {
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
result := testChannel(channel, "")
result := testChannel(channel, "", "")
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
@@ -477,7 +571,7 @@ func testAllChannels(notify bool) error {
// 当错误检查通过,才检查响应时间
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true
}
@@ -514,7 +608,6 @@ func TestAllChannels(c *gin.Context) {
"success": true,
"message": "",
})
return
}
var autoTestChannelsOnce sync.Once

View File

@@ -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,

View File

@@ -178,4 +178,4 @@ func boolToString(b bool) string {
return "true"
}
return "false"
}
}

View File

@@ -13,6 +13,7 @@ import (
"one-api/relay"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/setting/ratio_setting"
"time"
)
@@ -120,6 +121,91 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
task.FailReason = taskResult.Url
}
// 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
if taskResult.TotalTokens > 0 {
// 获取模型名称
var taskData map[string]interface{}
if err := json.Unmarshal(task.Data, &taskData); err == nil {
if modelName, ok := taskData["model"].(string); ok && modelName != "" {
// 获取模型价格和倍率
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
// 只有配置了倍率(非固定价格)时才按 token 重新计费
if hasRatioSetting && modelRatio > 0 {
// 获取用户和组的倍率信息
user, err := model.GetUserById(task.UserId, false)
if err == nil {
groupRatio := ratio_setting.GetGroupRatio(user.Group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group)
var finalGroupRatio float64
if hasUserGroupRatio {
finalGroupRatio = userGroupRatio
} else {
finalGroupRatio = groupRatio
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
// 计算差额
preConsumedQuota := task.Quota
quotaDelta := actualQuota - preConsumedQuota
if quotaDelta > 0 {
// 需要补扣费
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s实际消耗%s预扣费%stokens%d",
task.TaskID,
logger.LogQuota(quotaDelta),
logger.LogQuota(actualQuota),
logger.LogQuota(preConsumedQuota),
taskResult.TotalTokens,
))
if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil {
logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
} else {
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
task.Quota = actualQuota // 更新任务记录的实际扣费额度
// 记录消费日志
logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2ftokens %d预扣费 %s实际扣费 %s补扣费 %s",
modelRatio, finalGroupRatio, taskResult.TotalTokens,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
} else if quotaDelta < 0 {
// 需要退还多扣的费用
refundQuota := -quotaDelta
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s实际消耗%s预扣费%stokens%d",
task.TaskID,
logger.LogQuota(refundQuota),
logger.LogQuota(actualQuota),
logger.LogQuota(preConsumedQuota),
taskResult.TotalTokens,
))
if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
} else {
task.Quota = actualQuota // 更新任务记录的实际扣费额度
// 记录退款日志
logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2ftokens %d预扣费 %s实际扣费 %s退还 %s",
modelRatio, finalGroupRatio, taskResult.TotalTokens,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
} else {
// quotaDelta == 0, 预扣费刚好准确
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%stokens%d",
task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
}
}
}
}
}
}
case model.TaskStatusFailure:
task.Status = model.TaskStatusFailure
task.Progress = "100%"

View File

@@ -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()

View File

@@ -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)

View File

@@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct {
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
BarkUrl string `json:"bark_url,omitempty"`
GotifyUrl string `json:"gotify_url,omitempty"`
GotifyToken string `json:"gotify_token,omitempty"`
GotifyPriority int `json:"gotify_priority,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
@@ -1117,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的预警类型",
@@ -1192,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) {
}
}
// 如果是Gotify类型验证Gotify URL和Token
if req.QuotaWarningType == dto.NotifyTypeGotify {
if req.GotifyUrl == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify服务器地址不能为空",
})
return
}
if req.GotifyToken == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify令牌不能为空",
})
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的Gotify服务器地址",
})
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify服务器地址必须以http://或https://开头",
})
return
}
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
@@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) {
settings.BarkUrl = req.BarkUrl
}
// 如果是Gotify类型添加Gotify配置到设置中
if req.QuotaWarningType == dto.NotifyTypeGotify {
settings.GotifyUrl = req.GotifyUrl
settings.GotifyToken = req.GotifyToken
// Gotify优先级范围0-10超出范围则使用默认值5
if req.GotifyPriority < 0 || req.GotifyPriority > 10 {
settings.GotifyPriority = 5
} else {
settings.GotifyPriority = req.GotifyPriority
}
}
// 更新用户设置
user.SetSetting(settings)
if err := user.Update(false); err != nil {

View File

@@ -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:

View 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.

View File

@@ -20,6 +20,9 @@ type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {

View File

@@ -195,12 +195,15 @@ type ClaudeRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
//ClaudeMetadata `json:"metadata,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools any `json:"tools,omitempty"`
ContextManagement json.RawMessage `json:"context_management,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
ServiceTier string `json:"service_tier,omitempty"`
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {

View File

@@ -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

View File

@@ -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{} {

View File

@@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct {
Dimensions int `json:"dimensions,omitempty"`
Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"`
// 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户
// 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私
SafetyIdentifier string `json:"safety_identifier,omitempty"`
// Whether or not to store the output of this chat completion request for use in our model distillation or evals products.
// 是否存储此次请求数据供 OpenAI 用于评估和优化产品
// 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用
Store json.RawMessage `json:"store,omitempty"`
// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
LogitBias json.RawMessage `json:"logit_bias,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Prediction json.RawMessage `json:"prediction,omitempty"`
// gemini
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
//xai
@@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct {
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
ServiceTier string `json:"service_tier,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
}
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {

View File

@@ -7,6 +7,9 @@ type UserSetting struct {
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
@@ -16,4 +19,5 @@ var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
NotifyTypeGotify = "gotify" // Gotify 推送
)

17
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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)
}
}

View File

@@ -169,6 +169,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
err = common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
return nil, false, errors.New("video无效的请求, " + err.Error())
}
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID

View File

@@ -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":

View File

@@ -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)

View File

@@ -7,7 +7,6 @@ import (
"one-api/dto"
"one-api/relay/channel/claude"
relaycommon "one-api/relay/common"
"one-api/setting/model_setting"
"one-api/types"
"github.com/gin-gonic/gin"
@@ -52,11 +51,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
anthropicBeta := c.Request.Header.Get("anthropic-beta")
if anthropicBeta != "" {
req.Set("anthropic-beta", anthropicBeta)
}
model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
claude.CommonClaudeHeadersOperation(c, req, info)
return nil
}

View File

@@ -64,6 +64,15 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return baseURL, nil
}
func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
// common headers operation
anthropicBeta := c.Request.Header.Get("anthropic-beta")
if anthropicBeta != "" {
req.Set("anthropic-beta", anthropicBeta)
}
model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("x-api-key", info.ApiKey)
@@ -72,11 +81,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
anthropicVersion = "2023-06-01"
}
req.Set("anthropic-version", anthropicVersion)
anthropicBeta := c.Request.Header.Get("anthropic-beta")
if anthropicBeta != "" {
req.Set("anthropic-beta", anthropicBeta)
}
model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
CommonClaudeHeadersOperation(c, req, info)
return nil
}

View File

@@ -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
}
}

View File

@@ -76,6 +76,7 @@ func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dt
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
request.EncodingFormat = ""
return request, nil
}

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
if streamResponse.Item != nil {
switch streamResponse.Item.Type {
case dto.BuildInCallWebSearchCall:
info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++
if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil {
if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil {
webSearchTool.CallCount++
}
}
}
}
}

View File

@@ -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
}

View File

@@ -13,4 +13,4 @@ var ModelList = []string{
"deepseek-ai/DeepSeek-V3.1",
}
const ChannelName = "submodel"
const ChannelName = "submodel"

View File

@@ -0,0 +1,248 @@
package doubao
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/service"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
// ============================
// Request / Response structures
// ============================
type ContentItem struct {
Type string `json:"type"` // "text" or "image_url"
Text string `json:"text,omitempty"` // for text type
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
}
type ImageURL struct {
URL string `json:"url"`
}
type requestPayload struct {
Model string `json:"model"`
Content []ContentItem `json:"content"`
}
type responsePayload struct {
ID string `json:"id"` // task_id
}
type responseTask struct {
ID string `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
Content struct {
VideoURL string `json:"video_url"`
} `json:"content"`
Seed int `json:"seed"`
Resolution string `json:"resolution"`
Duration int `json:"duration"`
Ratio string `json:"ratio"`
FramesPerSecond int `json:"framespersecond"`
Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ============================
// Adaptor implementation
// ============================
type TaskAdaptor struct {
ChannelType int
apiKey string
baseURL string
}
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl
a.apiKey = info.ApiKey
}
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+a.apiKey)
return nil
}
// BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request")
if !exists {
return nil, fmt.Errorf("request not found in context")
}
req := v.(relaycommon.TaskSubmitReq)
body, err := a.convertToRequestPayload(&req)
if err != nil {
return nil, errors.Wrap(err, "convert request payload failed")
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bytes.NewReader(data), nil
}
// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
// DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
return
}
_ = resp.Body.Close()
// Parse Doubao response
var dResp responsePayload
if err := json.Unmarshal(responseBody, &dResp); err != nil {
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
return
}
if dResp.ID == "" {
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
return dResp.ID, responseBody, nil
}
// FetchTask fetch task status
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
taskID, ok := body["task_id"].(string)
if !ok {
return nil, fmt.Errorf("invalid task_id")
}
uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID)
req, err := http.NewRequest(http.MethodGet, uri, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
return service.GetHttpClient().Do(req)
}
func (a *TaskAdaptor) GetModelList() []string {
return ModelList
}
func (a *TaskAdaptor) GetChannelName() string {
return ChannelName
}
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{
Model: req.Model,
Content: []ContentItem{},
}
// Add text prompt
if req.Prompt != "" {
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
}
// Add images if present
if req.HasImage() {
for _, imgURL := range req.Images {
r.Content = append(r.Content, ContentItem{
Type: "image_url",
ImageURL: &ImageURL{
URL: imgURL,
},
})
}
}
// TODO: Add support for additional parameters from metadata
// such as ratio, duration, seed, etc.
// metadata := req.Metadata
// if metadata != nil {
// // Parse and apply metadata parameters
// }
return &r, nil
}
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
resTask := responseTask{}
if err := json.Unmarshal(respBody, &resTask); err != nil {
return nil, errors.Wrap(err, "unmarshal task result failed")
}
taskResult := relaycommon.TaskInfo{
Code: 0,
}
// Map Doubao status to internal status
switch resTask.Status {
case "pending", "queued":
taskResult.Status = model.TaskStatusQueued
taskResult.Progress = "10%"
case "processing":
taskResult.Status = model.TaskStatusInProgress
taskResult.Progress = "50%"
case "succeeded":
taskResult.Status = model.TaskStatusSuccess
taskResult.Progress = "100%"
taskResult.Url = resTask.Content.VideoURL
// 解析 usage 信息用于按倍率计费
taskResult.CompletionTokens = resTask.Usage.CompletionTokens
taskResult.TotalTokens = resTask.Usage.TotalTokens
case "failed":
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
taskResult.Reason = "task failed"
default:
// Unknown status, treat as processing
taskResult.Status = model.TaskStatusInProgress
taskResult.Progress = "30%"
}
return &taskResult, nil
}

View File

@@ -0,0 +1,9 @@
package doubao
var ModelList = []string{
"doubao-seedance-1-0-pro-250528",
"doubao-seedance-1-0-lite-t2v",
"doubao-seedance-1-0-lite-i2v",
}
var ChannelName = "doubao-video"

View File

@@ -91,7 +91,43 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
}
a.AccountCredentials = *adc
if a.RequestMode == RequestModeLlama {
if a.RequestMode == RequestModeGemini {
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else if a.RequestMode == RequestModeClaude {
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else if a.RequestMode == RequestModeLlama {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
region,
@@ -99,42 +135,33 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
region,
), nil
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else {
var keyPrefix string
if strings.HasSuffix(suffix, "?alt=sse") {
keyPrefix = "&"
} else {
keyPrefix = "?"
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
modelName,
suffix,
keyPrefix,
info.ApiKey,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
region,
modelName,
suffix,
keyPrefix,
info.ApiKey,
), nil
}
}
return "", errors.New("unsupported request mode")
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -188,7 +215,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
req.Set("Authorization", "Bearer "+accessToken)
}
if a.AccountCredentials.ProjectID != "" {
if a.AccountCredentials.ProjectID != "" {
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
}
return nil

View File

@@ -195,21 +195,29 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
}
switch info.RelayMode {
case constant.RelayModeChatCompletions:
switch info.RelayFormat {
case types.RelayFormatClaude:
if strings.HasPrefix(info.UpstreamModelName, "bot") {
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
}
return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
case constant.RelayModeImagesEdits:
return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
case constant.RelayModeRerank:
return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
default:
switch info.RelayMode {
case constant.RelayModeChatCompletions:
if strings.HasPrefix(info.UpstreamModelName, "bot") {
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
}
return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
case constant.RelayModeImagesEdits:
return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
case constant.RelayModeRerank:
return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
default:
}
}
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
}

View File

@@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for Claude API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -500,10 +500,52 @@ func (t TaskSubmitReq) HasImage() bool {
}
type TaskInfo struct {
Code int `json:"code"`
TaskID string `json:"task_id"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Url string `json:"url,omitempty"`
Progress string `json:"progress,omitempty"`
Code int `json:"code"`
TaskID string `json:"task_id"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Url string `json:"url,omitempty"`
Progress string `json:"progress,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费
TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
}
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
// service_tier: 服务层级字段可能导致额外计费OpenAI、Claude、Responses API 支持)
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) {
var data map[string]interface{}
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error())
return jsonData, nil
}
// 默认移除 service_tier除非明确允许避免额外计费风险
if !channelOtherSettings.AllowServiceTier {
if _, exists := data["service_tier"]; exists {
delete(data, "service_tier")
}
}
// 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
if channelOtherSettings.DisableStore {
if _, exists := data["store"]; exists {
delete(data, "store")
}
}
// 默认移除 safety_identifier除非明确允许保护用户隐私避免向 OpenAI 报告用户信息)
if !channelOtherSettings.AllowSafetyIdentifier {
if _, exists := data["safety_identifier"]; exists {
delete(data, "safety_identifier")
}
}
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveDisabledFields Marshal error :" + err.Error())
return jsonData, nil
}
return jsonDataAfter, nil
}

View File

@@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for OpenAI API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -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:

View File

@@ -1,6 +1,7 @@
package relay
import (
"github.com/gin-gonic/gin"
"one-api/constant"
"one-api/relay/channel"
"one-api/relay/channel/ali"
@@ -24,6 +25,8 @@ import (
"one-api/relay/channel/palm"
"one-api/relay/channel/perplexity"
"one-api/relay/channel/siliconflow"
"one-api/relay/channel/submodel"
taskdoubao "one-api/relay/channel/task/doubao"
taskjimeng "one-api/relay/channel/task/jimeng"
"one-api/relay/channel/task/kling"
"one-api/relay/channel/task/suno"
@@ -37,8 +40,6 @@ import (
"one-api/relay/channel/zhipu"
"one-api/relay/channel/zhipu_4v"
"strconv"
"one-api/relay/channel/submodel"
"github.com/gin-gonic/gin"
)
func GetAdaptor(apiType int) channel.Adaptor {
@@ -134,6 +135,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
return &taskvertex.TaskAdaptor{}
case constant.ChannelTypeVidu:
return &taskVidu.TaskAdaptor{}
case constant.ChannelTypeDoubaoVideo:
return &taskdoubao.TaskAdaptor{}
}
}
return nil

View File

@@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for OpenAI Responses API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
// Bark推送使用简短文本不支持HTML
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else if notifyType == dto.NotifyTypeGotify {
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else {
// 默认内容格式适用于Email和Webhook
// 默认内容格式适用于Email和Webhook支持HTML
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
}

View File

@@ -1,6 +1,8 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
@@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
switch notifyType {
case dto.NotifyTypeEmail:
// check setting email
userEmail = userSetting.NotificationEmail
if userEmail == "" {
// 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱
emailToUse := userSetting.NotificationEmail
if emailToUse == "" {
emailToUse = userEmail
}
if emailToUse == "" {
common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId))
return nil
}
return sendEmailNotify(userEmail, data)
return sendEmailNotify(emailToUse, data)
case dto.NotifyTypeWebhook:
webhookURLStr := userSetting.WebhookUrl
if webhookURLStr == "" {
@@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
return nil
}
return sendBarkNotify(barkURL, data)
case dto.NotifyTypeGotify:
gotifyUrl := userSetting.GotifyUrl
gotifyToken := userSetting.GotifyToken
if gotifyUrl == "" || gotifyToken == "" {
common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId))
return nil
}
return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data)
}
return nil
}
@@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
return nil
}
func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error {
// 处理占位符
content := data.Content
for _, value := range data.Values {
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
}
// 构建完整的 Gotify API URL
// 确保 URL 以 /message 结尾
finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken)
// Gotify优先级范围0-10如果超出范围则使用默认值5
if priority < 0 || priority > 10 {
priority = 5
}
// 构建 JSON payload
type GotifyMessage struct {
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
}
payload := GotifyMessage{
Title: data.Title,
Message: content,
Priority: priority,
}
// 序列化为 JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal gotify payload: %v", err)
}
var req *http.Request
var resp *http.Response
if system_setting.EnableWorker() {
// 使用worker发送请求
workerReq := &WorkerRequest{
URL: finalURL,
Key: system_setting.WorkerValidKey,
Method: http.MethodPost,
Headers: map[string]string{
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "OneAPI-Gotify-Notify/1.0",
},
Body: payloadBytes,
}
resp, err = DoWorkerRequest(workerReq)
if err != nil {
return fmt.Errorf("failed to send gotify request through worker: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
}
} else {
// SSRF防护验证Gotify URL非Worker模式
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return fmt.Errorf("request reject: %v", err)
}
// 直接发送请求
req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes))
if err != nil {
return fmt.Errorf("failed to create gotify request: %v", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0")
// 发送请求
client := GetHttpClient()
resp, err = client.Do(req)
if err != nil {
return fmt.Errorf("failed to send gotify request: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
}
}
return nil
}

View File

@@ -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> 的 XTOKENS 不适用)
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
}
}

View File

@@ -29,6 +29,7 @@ const (
Gemini25FlashLitePreviewInputAudioPrice = 0.50
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
GeminiRoboticsER15InputAudioPrice = 1.00
)
const (
@@ -74,6 +75,8 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
return Gemini25FlashProductionInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
return GeminiRoboticsER15InputAudioPrice
}
return 0
}

View File

@@ -179,6 +179,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
"gemini-robotics-er-1.5-preview": 0.15,
"gemini-embedding-001": 0.075,
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
@@ -252,17 +253,17 @@ var defaultModelRatio = map[string]float64{
"grok-vision-beta": 2.5,
"grok-3-fast-beta": 2.5,
"grok-3-mini-fast-beta": 0.3,
// submodel
"NousResearch/Hermes-4-405B-FP8": 0.8,
"Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
"Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3,
"zai-org/GLM-4.5-FP8": 0.8,
"openai/gpt-oss-120b": 0.5,
"deepseek-ai/DeepSeek-R1-0528": 0.8,
"deepseek-ai/DeepSeek-R1": 0.8,
"deepseek-ai/DeepSeek-V3-0324": 0.8,
"deepseek-ai/DeepSeek-V3.1": 0.8,
// submodel
"NousResearch/Hermes-4-405B-FP8": 0.8,
"Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
"Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3,
"zai-org/GLM-4.5-FP8": 0.8,
"openai/gpt-oss-120b": 0.5,
"deepseek-ai/DeepSeek-R1-0528": 0.8,
"deepseek-ai/DeepSeek-R1": 0.8,
"deepseek-ai/DeepSeek-V3-0324": 0.8,
"deepseek-ai/DeepSeek-V3.1": 0.8,
}
var defaultModelPrice = map[string]float64{
@@ -587,6 +588,8 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 4, false
}
return 2.5 / 0.3, false
} else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
return 2.5 / 0.3, false
}
return 4, false
}

View File

@@ -10,7 +10,7 @@
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
/>
<title>New API</title>
<analytics></analytics>
<analytics></analytics>
</head>
<body>

View File

@@ -6,4 +6,4 @@
}
},
"include": ["src/**/*"]
}
}

View File

@@ -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>

View File

@@ -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
)}

View File

@@ -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') &&

View File

@@ -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,

View File

@@ -81,6 +81,9 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
barkUrl: '',
gotifyUrl: '',
gotifyToken: '',
gotifyPriority: 5,
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
@@ -149,6 +152,12 @@ const PersonalSetting = () => {
webhookSecret: settings.webhook_secret || '',
notificationEmail: settings.notification_email || '',
barkUrl: settings.bark_url || '',
gotifyUrl: settings.gotify_url || '',
gotifyToken: settings.gotify_token || '',
gotifyPriority:
settings.gotify_priority !== undefined
? settings.gotify_priority
: 5,
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
@@ -406,6 +415,12 @@ const PersonalSetting = () => {
webhook_secret: notificationSettings.webhookSecret,
notification_email: notificationSettings.notificationEmail,
bark_url: notificationSettings.barkUrl,
gotify_url: notificationSettings.gotifyUrl,
gotify_token: notificationSettings.gotifyToken,
gotify_priority: (() => {
const parsed = parseInt(notificationSettings.gotifyPriority);
return isNaN(parsed) ? 5 : parsed;
})(),
accept_unset_model_ratio_model:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,

View File

@@ -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>

View File

@@ -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('未启用')}

View File

@@ -400,6 +400,7 @@ const NotificationSettings = ({
<Radio value='email'>{t('邮件通知')}</Radio>
<Radio value='webhook'>{t('Webhook通知')}</Radio>
<Radio value='bark'>{t('Bark通知')}</Radio>
<Radio value='gotify'>{t('Gotify通知')}</Radio>
</Form.RadioGroup>
<Form.AutoComplete
@@ -589,7 +590,108 @@ const NotificationSettings = ({
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Bark 官方文档
Bark {t('官方文档')}
</a>
</div>
</div>
</div>
</>
)}
{/* Gotify推送设置 */}
{notificationSettings.warningType === 'gotify' && (
<>
<Form.Input
field='gotifyUrl'
label={t('Gotify服务器地址')}
placeholder={t(
'请输入Gotify服务器地址例如: https://gotify.example.com',
)}
onChange={(val) => handleFormChange('gotifyUrl', val)}
prefix={<IconLink />}
extraText={t(
'支持HTTP和HTTPS填写Gotify服务器的完整URL地址',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify服务器地址'),
},
{
pattern: /^https?:\/\/.+/,
message: t('Gotify服务器地址必须以http://或https://开头'),
},
]}
/>
<Form.Input
field='gotifyToken'
label={t('Gotify应用令牌')}
placeholder={t('请输入Gotify应用令牌')}
onChange={(val) => handleFormChange('gotifyToken', val)}
prefix={<IconKey />}
extraText={t(
'在Gotify服务器创建应用后获得的令牌用于发送通知',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify应用令牌'),
},
]}
/>
<Form.AutoComplete
field='gotifyPriority'
label={t('消息优先级')}
placeholder={t('请选择消息优先级')}
data={[
{ value: 0, label: t('0 - 最低') },
{ value: 2, label: t('2 - 低') },
{ value: 5, label: t('5 - 正常(默认)') },
{ value: 8, label: t('8 - 高') },
{ value: 10, label: t('10 - 最高') },
]}
onChange={(val) =>
handleFormChange('gotifyPriority', val)
}
prefix={<IconBell />}
extraText={t('消息优先级范围0-10默认为5')}
style={{ width: '100%', maxWidth: '300px' }}
/>
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
<div className='text-sm text-gray-700 mb-3'>
<strong>{t('配置说明')}</strong>
</div>
<div className='text-xs text-gray-500 space-y-2'>
<div>
1. {t('在Gotify服务器的应用管理中创建新应用')}
</div>
<div>
2.{' '}
{t(
'复制应用的令牌Token并填写到上方的应用令牌字段',
)}
</div>
<div>
3. {t('填写Gotify服务器的完整URL地址')}
</div>
<div className='mt-3 pt-3 border-t border-gray-200'>
<span className='text-gray-400'>
{t('更多信息请参考')}
</span>{' '}
<a
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Gotify {t('官方文档')}
</a>
</div>
</div>

View File

@@ -68,6 +68,8 @@ import {
IconCode,
IconGlobe,
IconBolt,
IconChevronUp,
IconChevronDown,
} from '@douyinfe/semi-icons';
const { Text, Title } = Typography;
@@ -89,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) {
@@ -169,6 +155,10 @@ const EditChannelModal = (props) => {
vertex_key_type: 'json',
// 企业账户设置
is_enterprise_account: false,
// 字段透传控制默认值
allow_service_tier: false,
disable_store: false, // false = 允许透传(默认开启)
allow_safety_identifier: false,
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
@@ -202,6 +192,27 @@ const EditChannelModal = (props) => {
keyData: '',
});
// 专门的2FA验证状态用于TwoFactorAuthModal
const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
const [verifyCode, setVerifyCode] = useState('');
const [verifyLoading, setVerifyLoading] = useState(false);
// 表单块导航相关状态
const formSectionRefs = useRef({
basicInfo: null,
apiConfig: null,
modelConfig: null,
advancedSettings: null,
channelExtraSettings: null,
});
const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings'];
const formContainerRef = useRef(null);
// 2FA状态更新辅助函数
const updateTwoFAState = (updates) => {
setTwoFAState((prev) => ({ ...prev, ...updates }));
};
// 使用通用安全验证 Hook
const {
isModalVisible,
@@ -241,6 +252,44 @@ const EditChannelModal = (props) => {
});
};
// 重置2FA验证状态
const reset2FAVerifyState = () => {
setShow2FAVerifyModal(false);
setVerifyCode('');
setVerifyLoading(false);
};
// 表单导航功能
const scrollToSection = (sectionKey) => {
const sectionElement = formSectionRefs.current[sectionKey];
if (sectionElement) {
sectionElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
};
const navigateToSection = (direction) => {
const availableSections = formSections.filter(section => {
if (section === 'apiConfig') {
return showApiConfigCard;
}
return true;
});
let newIndex;
if (direction === 'up') {
newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1;
} else {
newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0;
}
setCurrentSectionIndex(newIndex);
scrollToSection(availableSections[newIndex]);
};
// 渠道额外设置状态
const [channelSettings, setChannelSettings] = useState({
force_format: false,
@@ -358,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);
@@ -452,18 +504,29 @@ 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;
data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false;
} catch (error) {
console.error('解析其他设置失败:', error);
data.azure_responses_version = '';
data.region = '';
data.vertex_key_type = 'json';
data.is_enterprise_account = false;
data.allow_service_tier = false;
data.disable_store = false;
data.allow_safety_identifier = false;
}
} else {
// 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
data.vertex_key_type = 'json';
data.is_enterprise_account = false;
data.allow_service_tier = false;
data.disable_store = false;
data.allow_safety_identifier = false;
}
if (
@@ -715,6 +778,8 @@ const EditChannelModal = (props) => {
fetchModelGroups();
// 重置手动输入模式状态
setUseManualInput(false);
// 重置导航状态
setCurrentSectionIndex(0);
} else {
// 统一的模态框关闭重置逻辑
resetModalState();
@@ -867,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;
}
@@ -900,21 +968,34 @@ const EditChannelModal = (props) => {
};
localInputs.setting = JSON.stringify(channelExtraSettings);
// 处理type === 20的企业账户设置
if (localInputs.type === 20) {
let settings = {};
if (localInputs.settings) {
try {
settings = JSON.parse(localInputs.settings);
} catch (error) {
console.error('解析settings失败:', error);
}
// 处理 settings 字段(包括企业账户设置和字段透传控制)
let settings = {};
if (localInputs.settings) {
try {
settings = JSON.parse(localInputs.settings);
} catch (error) {
console.error('解析settings失败:', error);
}
// 设置企业账户标识无论是true还是false都要传到后端
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
localInputs.settings = JSON.stringify(settings);
}
// type === 20: 设置企业账户标识无论是true还是false都要传到后端
if (localInputs.type === 20) {
settings.openrouter_enterprise =
localInputs.is_enterprise_account === true;
}
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
if (localInputs.type === 1 || localInputs.type === 14) {
settings.allow_service_tier = localInputs.allow_service_tier === true;
// 仅 OpenAI 渠道需要 store 和 safety_identifier
if (localInputs.type === 1) {
settings.disable_store = localInputs.disable_store === true;
settings.allow_safety_identifier = localInputs.allow_safety_identifier === true;
}
}
localInputs.settings = JSON.stringify(settings);
// 清理不需要发送到后端的字段
delete localInputs.force_format;
delete localInputs.thinking_to_content;
@@ -925,6 +1006,10 @@ const EditChannelModal = (props) => {
delete localInputs.is_enterprise_account;
// 顶层的 vertex_key_type 不应发送给后端
delete localInputs.vertex_key_type;
// 清理字段透传控制的临时字段
delete localInputs.allow_service_tier;
delete localInputs.disable_store;
delete localInputs.allow_safety_identifier;
let res;
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
@@ -1240,7 +1325,41 @@ const EditChannelModal = (props) => {
visible={props.visible}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<div className='flex justify-between items-center bg-white'>
<div className='flex gap-2'>
<Button
size='small'
type='tertiary'
icon={<IconChevronUp />}
onClick={() => navigateToSection('up')}
style={{
borderRadius: '50%',
width: '32px',
height: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={t('上一个表单块')}
/>
<Button
size='small'
type='tertiary'
icon={<IconChevronDown />}
onClick={() => navigateToSection('down')}
style={{
borderRadius: '50%',
width: '32px',
height: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={t('下一个表单块')}
/>
</div>
<Space>
<Button
theme='solid'
@@ -1271,10 +1390,14 @@ const EditChannelModal = (props) => {
>
{() => (
<Spin spinning={loading}>
<div className='p-2'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<div
className='p-2'
ref={formContainerRef}
>
<div ref={el => formSectionRefs.current.basicInfo = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='blue'
@@ -1317,7 +1440,9 @@ const EditChannelModal = (props) => {
setIsEnterpriseAccount(value);
handleInputChange('is_enterprise_account', value);
}}
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
extraText={t(
'企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选',
)}
initValue={inputs.is_enterprise_account}
/>
)}
@@ -1748,13 +1873,15 @@ const EditChannelModal = (props) => {
}
/>
)}
</Card>
</Card>
</div>
{/* API Configuration Card */}
{showApiConfigCard && (
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.apiConfig = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
@@ -1943,35 +2070,37 @@ 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>
</Card>
</div>
)}
{/* Model Configuration Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.modelConfig = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='purple'
@@ -2166,12 +2295,14 @@ const EditChannelModal = (props) => {
formApi={formApiRef.current}
extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
/>
</Card>
</Card>
</div>
{/* Advanced Settings Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.advancedSettings = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='orange'
@@ -2330,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
@@ -2384,12 +2527,84 @@ const EditChannelModal = (props) => {
'键为原状态码,值为要复写的状态码,仅影响本地判断',
)}
/>
</Card>
{/* 字段透传控制 - OpenAI 渠道 */}
{inputs.type === 1 && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
</div>
<Form.Switch
field='allow_service_tier'
label={t('允许 service_tier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
)}
/>
<Form.Switch
field='disable_store'
label={t('禁用 store 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('disable_store', value)
}
extraText={t(
'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用',
)}
/>
<Form.Switch
field='allow_safety_identifier'
label={t('允许 safety_identifier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_safety_identifier', value)
}
extraText={t(
'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
)}
/>
</>
)}
{/* 字段透传控制 - Claude 渠道 */}
{(inputs.type === 14) && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
</div>
<Form.Switch
field='allow_service_tier'
label={t('允许 service_tier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
)}
/>
</>
)}
</Card>
</div>
{/* Channel Extra Settings Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.channelExtraSettings = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='violet'
@@ -2487,7 +2702,8 @@ const EditChannelModal = (props) => {
'如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面',
)}
/>
</Card>
</Card>
</div>
</div>
</Spin>
)}

View File

@@ -25,6 +25,7 @@ import {
Table,
Tag,
Typography,
Select,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
@@ -45,6 +46,8 @@ const ModelTestModal = ({
testChannel,
modelTablePage,
setModelTablePage,
selectedEndpointType,
setSelectedEndpointType,
allSelectingRef,
isMobile,
t,
@@ -59,6 +62,17 @@ const ModelTestModal = ({
)
: [];
const endpointTypeOptions = [
{ value: '', label: t('自动检测') },
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
{ value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' },
{ value: 'jina-rerank', label: 'Jina Rerank (/rerank)' },
{ value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' },
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
];
const handleCopySelected = () => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
@@ -152,7 +166,7 @@ const ModelTestModal = ({
return (
<Button
type='tertiary'
onClick={() => testChannel(currentTestChannel, record.model)}
onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)}
loading={isTesting}
size='small'
>
@@ -228,6 +242,18 @@ const ModelTestModal = ({
>
{hasChannel && (
<div className='model-test-scroll'>
{/* 端点类型选择器 */}
<div className='flex items-center gap-2 w-full mb-2'>
<Typography.Text strong>{t('端点类型')}:</Typography.Text>
<Select
value={selectedEndpointType}
onChange={setSelectedEndpointType}
optionList={endpointTypeOptions}
className='!w-full'
placeholder={t('选择端点类型')}
/>
</div>
{/* 搜索与操作按钮 */}
<div className='flex items-center justify-end gap-2 w-full mb-2'>
<Input

View File

@@ -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>

View File

@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
const currencyItems = [
{ value: 'USD', label: 'USD ($)' },
{ value: 'CNY', label: 'CNY (¥)' },
{ value: 'CUSTOM', label: t('自定义货币') },
];
const handleChange = (value) => {

View File

@@ -107,6 +107,7 @@ const SearchActions = memo(
optionList={[
{ value: 'USD', label: 'USD' },
{ value: 'CNY', label: 'CNY' },
{ value: 'CUSTOM', label: t('自定义货币') },
]}
/>
)}

View File

@@ -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';

View File

@@ -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>

View File

@@ -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}
>

View File

@@ -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 &&

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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={

View File

@@ -164,6 +164,11 @@ export const CHANNEL_OPTIONS = [
color: 'blue',
label: 'SubModel',
},
{
value: 54,
color: 'blue',
label: '豆包视频',
},
];
export const MODEL_TABLE_PAGE_SIZE = 10;

View File

@@ -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(),
},
];

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -337,6 +337,8 @@ export function getChannelIcon(channelType) {
return <Kling.Color size={iconSize} />;
case 51: // 即梦 Jimeng
return <Jimeng.Color size={iconSize} />;
case 54: // 豆包视频 Doubao Video
return <Doubao.Color size={iconSize} />;
case 8: // 自定义渠道
case 22: // 知识库FastGPT
return <FastGPT.Color size={iconSize} />;
@@ -830,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) {
@@ -887,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) {
@@ -1035,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,
},
);
@@ -1072,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,
},
)}
@@ -1098,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,
},
)}
@@ -1110,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,
},
)}
@@ -1122,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>
)}
@@ -1147,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,
},
@@ -1200,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,
},
@@ -1211,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,
},
@@ -1222,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,
},
@@ -1233,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),
},
);
})()}
@@ -1269,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,
});
@@ -1365,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,
},
);
@@ -1407,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,
},
)}
@@ -1424,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,
},
)}
@@ -1435,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,
},
@@ -1457,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>
@@ -1510,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 '';
@@ -1536,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 {
@@ -1573,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,
},
)}
@@ -1603,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,
},
)}
@@ -1617,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>
@@ -1668,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,
});

View File

@@ -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,

View File

@@ -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,7 +84,8 @@ export const useChannelsData = () => {
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1);
const [selectedEndpointType, setSelectedEndpointType] = useState('');
// 使用 ref 来避免闭包问题,类似旧版实现
const shouldStopBatchTestingRef = useRef(false);
@@ -116,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);
@@ -176,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]);
@@ -290,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}`,
@@ -311,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);
@@ -335,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}`,
@@ -347,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);
@@ -366,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,
);
}
};
@@ -452,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,
);
}
};
@@ -470,7 +515,14 @@ export const useChannelsData = () => {
showError(reason);
});
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
1,
size,
idSort,
);
}
};
@@ -501,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),
);
}
};
@@ -540,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;
}
@@ -683,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);
@@ -691,7 +754,7 @@ export const useChannelsData = () => {
};
// Test channel - 单个模型测试,参考旧版实现
const testChannel = async (record, model) => {
const testChannel = async (record, model, endpointType = '') => {
const testKey = `${record.id}-${model}`;
// 检查是否应该停止批量测试
@@ -700,10 +763,14 @@ export const useChannelsData = () => {
}
// 添加到正在测试的模型集合
setTestingModels(prev => new Set([...prev, model]));
setTestingModels((prev) => new Set([...prev, model]));
try {
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
let url = `/api/channel/test/${record.id}?model=${model}`;
if (endpointType) {
url += `&endpoint_type=${endpointType}`;
}
const res = await API.get(url);
// 检查是否在请求期间被停止
if (shouldStopBatchTestingRef.current && isBatchTesting) {
@@ -713,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) {
@@ -738,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)),
@@ -750,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;
@@ -777,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('没有找到匹配的模型'));
@@ -790,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];
});
@@ -800,7 +871,12 @@ export const useChannelsData = () => {
});
try {
showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
showInfo(
t('开始批量测试 ${count} 个模型,已清空上次结果...').replace(
'${count}',
models.length,
),
);
// 提高并发数量以加快测试速度,参考旧版的并发限制
const concurrencyLimit = 5;
@@ -814,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));
const batchPromises = batch.map((model) =>
testChannel(currentTestChannel, model, selectedEndpointType),
);
const batchResults = await Promise.allSettled(batchPromises);
results.push(...batchResults);
@@ -832,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) {
@@ -857,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);
@@ -902,6 +982,7 @@ export const useChannelsData = () => {
setTestingModels(new Set());
setSelectedModelKeys([]);
setModelTablePage(1);
setSelectedEndpointType('');
// 可选择性保留测试结果,这里不清空以便用户查看
};
@@ -989,6 +1070,8 @@ export const useChannelsData = () => {
isBatchTesting,
modelTablePage,
setModelTablePage,
selectedEndpointType,
setSelectedEndpointType,
allSelectingRef,
// Multi-key management states
@@ -1045,4 +1128,4 @@ export const useChannelsData = () => {
setCompactMode,
setActivePage,
};
};
};

View File

@@ -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]);

View File

@@ -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/CNYTOKENS 时仍允许切换视图内货币
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)}`;
};

View File

@@ -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",
@@ -1313,6 +1312,8 @@
"请输入Webhook地址例如: https://example.com/webhook": "Please enter the Webhook URL, e.g.: https://example.com/webhook",
"邮件通知": "Email notification",
"Webhook通知": "Webhook notification",
"Bark通知": "Bark notification",
"Gotify通知": "Gotify notification",
"接口凭证(可选)": "Interface credentials (optional)",
"密钥将以 Bearer 方式添加到请求头中用于验证webhook请求的合法性": "The secret will be added to the request header as a Bearer token to verify the legitimacy of the webhook request",
"Authorization: Bearer your-secret-key": "Authorization: Bearer your-secret-key",
@@ -1323,6 +1324,36 @@
"通知邮箱": "Notification email",
"设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used",
"留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used",
"Bark推送URL": "Bark Push URL",
"请输入Bark推送URL例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Please enter Bark push URL, e.g.: https://api.day.app/yourkey/{{title}}/{{content}}",
"支持HTTP和HTTPS模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Supports HTTP and HTTPS, template variables: {{title}} (notification title), {{content}} (notification content)",
"请输入Bark推送URL": "Please enter Bark push URL",
"Bark推送URL必须以http://或https://开头": "Bark push URL must start with http:// or https://",
"模板示例": "Template example",
"更多参数请参考": "For more parameters, please refer to",
"Gotify服务器地址": "Gotify server address",
"请输入Gotify服务器地址例如: https://gotify.example.com": "Please enter Gotify server address, e.g.: https://gotify.example.com",
"支持HTTP和HTTPS填写Gotify服务器的完整URL地址": "Supports HTTP and HTTPS, enter the complete URL of the Gotify server",
"请输入Gotify服务器地址": "Please enter Gotify server address",
"Gotify服务器地址必须以http://或https://开头": "Gotify server address must start with http:// or https://",
"Gotify应用令牌": "Gotify application token",
"请输入Gotify应用令牌": "Please enter Gotify application token",
"在Gotify服务器创建应用后获得的令牌用于发送通知": "Token obtained after creating an application on the Gotify server, used to send notifications",
"消息优先级": "Message priority",
"请选择消息优先级": "Please select message priority",
"0 - 最低": "0 - Lowest",
"2 - 低": "2 - Low",
"5 - 正常(默认)": "5 - Normal (default)",
"8 - 高": "8 - High",
"10 - 最高": "10 - Highest",
"消息优先级范围0-10默认为5": "Message priority, range 0-10, default is 5",
"配置说明": "Configuration instructions",
"在Gotify服务器的应用管理中创建新应用": "Create a new application in the Gotify server's application management",
"复制应用的令牌Token并填写到上方的应用令牌字段": "Copy the application token and fill it in the application token field above",
"填写Gotify服务器的完整URL地址": "Fill in the complete URL address of the Gotify server",
"更多信息请参考": "For more information, please refer to",
"通知内容": "Notification content",
"官方文档": "Official documentation",
"API地址": "Base URL",
"对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in",
"渠道额外设置": "Channel extra settings",
@@ -1446,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.",
@@ -1779,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",
@@ -2190,7 +2224,28 @@
"留空将自动使用服务器地址,多个 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",
"允许 safety_identifier 透传": "Allow safety_identifier Pass-through",
"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"
}

View File

@@ -1308,6 +1308,8 @@
"请输入Webhook地址例如: https://example.com/webhook": "Veuillez saisir l'URL du Webhook, par exemple : https://example.com/webhook",
"邮件通知": "Notification par e-mail",
"Webhook通知": "Notification par Webhook",
"Bark通知": "Notification Bark",
"Gotify通知": "Notification Gotify",
"接口凭证(可选)": "Informations d'identification de l'interface (facultatif)",
"密钥将以 Bearer 方式添加到请求头中用于验证webhook请求的合法性": "Le secret sera ajouté à l'en-tête de la requête en tant que jeton Bearer pour vérifier la légitimité de la requête webhook",
"Authorization: Bearer your-secret-key": "Autorisation : Bearer votre-clé-secrète",
@@ -1318,6 +1320,36 @@
"通知邮箱": "E-mail de notification",
"设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Définissez l'adresse e-mail pour recevoir les notifications d'avertissement de quota, si elle n'est pas définie, l'adresse e-mail liée au compte sera utilisée",
"留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée",
"Bark推送URL": "URL de notification Bark",
"请输入Bark推送URL例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Veuillez saisir l'URL de notification Bark, par exemple : https://api.day.app/yourkey/{{title}}/{{content}}",
"支持HTTP和HTTPS模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Prend en charge HTTP et HTTPS, variables de modèle : {{title}} (titre de la notification), {{content}} (contenu de la notification)",
"请输入Bark推送URL": "Veuillez saisir l'URL de notification Bark",
"Bark推送URL必须以http://或https://开头": "L'URL de notification Bark doit commencer par http:// ou https://",
"模板示例": "Exemple de modèle",
"更多参数请参考": "Pour plus de paramètres, veuillez vous référer à",
"Gotify服务器地址": "Adresse du serveur Gotify",
"请输入Gotify服务器地址例如: https://gotify.example.com": "Veuillez saisir l'adresse du serveur Gotify, par exemple : https://gotify.example.com",
"支持HTTP和HTTPS填写Gotify服务器的完整URL地址": "Prend en charge HTTP et HTTPS, saisissez l'URL complète du serveur Gotify",
"请输入Gotify服务器地址": "Veuillez saisir l'adresse du serveur Gotify",
"Gotify服务器地址必须以http://或https://开头": "L'adresse du serveur Gotify doit commencer par http:// ou https://",
"Gotify应用令牌": "Jeton d'application Gotify",
"请输入Gotify应用令牌": "Veuillez saisir le jeton d'application Gotify",
"在Gotify服务器创建应用后获得的令牌用于发送通知": "Jeton obtenu après la création d'une application sur le serveur Gotify, utilisé pour envoyer des notifications",
"消息优先级": "Priorité du message",
"请选择消息优先级": "Veuillez sélectionner la priorité du message",
"0 - 最低": "0 - La plus basse",
"2 - 低": "2 - Basse",
"5 - 正常(默认)": "5 - Normale (par défaut)",
"8 - 高": "8 - Haute",
"10 - 最高": "10 - La plus haute",
"消息优先级范围0-10默认为5": "Priorité du message, plage 0-10, par défaut 5",
"配置说明": "Instructions de configuration",
"在Gotify服务器的应用管理中创建新应用": "Créer une nouvelle application dans la gestion des applications du serveur Gotify",
"复制应用的令牌Token并填写到上方的应用令牌字段": "Copier le jeton de l'application et le remplir dans le champ de jeton d'application ci-dessus",
"填写Gotify服务器的完整URL地址": "Remplir l'adresse URL complète du serveur Gotify",
"更多信息请参考": "Pour plus d'informations, veuillez vous référer à",
"通知内容": "Contenu de la notification",
"官方文档": "Documentation officielle",
"API地址": "URL de base",
"对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写": "Pour les canaux officiels, le new-api a une adresse intégrée. Sauf s'il s'agit d'un site proxy tiers ou d'une adresse d'accès Azure spéciale, il n'est pas nécessaire de la remplir",
"渠道额外设置": "Paramètres supplémentaires du canal",
@@ -1774,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",
@@ -2001,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",
@@ -2135,7 +2169,74 @@
"关闭侧边栏": "Fermer la barre latérale",
"定价": "Tarification",
"语言": "Langue",
"字段透传控制": "Contrôle du passage des champs",
"允许 service_tier 透传": "Autoriser le passage de service_tier",
"禁用 store 透传": "Désactiver le passage de store",
"允许 safety_identifier 透传": "Autoriser le passage de safety_identifier",
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Le champ service_tier est utilisé pour spécifier le niveau de service. Permettre le passage peut entraîner une facturation plus élevée que prévu. Désactivé par défaut pour éviter des frais supplémentaires",
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex",
"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",
"允许不安全的 OriginHTTP": "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"
}

View File

@@ -18,7 +18,26 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import { Banner, Button, Form, Space, Spin } from '@douyinfe/semi-ui';
import {
Banner,
Button,
Form,
Space,
Spin,
RadioGroup,
Radio,
Table,
Modal,
Input,
Divider,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconEdit,
IconDelete,
IconSearch,
IconSaveStroked,
} from '@douyinfe/semi-icons';
import {
compareObjects,
API,
@@ -37,6 +56,52 @@ export default function SettingsChats(props) {
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const [editMode, setEditMode] = useState('visual');
const [chatConfigs, setChatConfigs] = useState([]);
const [modalVisible, setModalVisible] = useState(false);
const [editingConfig, setEditingConfig] = useState(null);
const [isEdit, setIsEdit] = useState(false);
const [searchText, setSearchText] = useState('');
const modalFormRef = useRef();
const jsonToConfigs = (jsonString) => {
try {
const configs = JSON.parse(jsonString);
return Array.isArray(configs)
? configs.map((config, index) => ({
id: index,
name: Object.keys(config)[0] || '',
url: Object.values(config)[0] || '',
}))
: [];
} catch (error) {
console.error('JSON parse error:', error);
return [];
}
};
const configsToJson = (configs) => {
const jsonArray = configs.map((config) => ({
[config.name]: config.url,
}));
return JSON.stringify(jsonArray, null, 2);
};
const syncJsonToConfigs = () => {
const configs = jsonToConfigs(inputs.Chats);
setChatConfigs(configs);
};
const syncConfigsToJson = (configs) => {
const jsonString = configsToJson(configs);
setInputs((prev) => ({
...prev,
Chats: jsonString,
}));
if (refForm.current && editMode === 'json') {
refForm.current.setValues({ Chats: jsonString });
}
};
async function onSubmit() {
try {
@@ -103,16 +168,184 @@ export default function SettingsChats(props) {
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
if (refForm.current) {
refForm.current.setValues(currentInputs);
}
// 同步到可视化配置
const configs = jsonToConfigs(currentInputs.Chats || '[]');
setChatConfigs(configs);
}, [props.options]);
useEffect(() => {
if (editMode === 'visual') {
syncJsonToConfigs();
}
}, [inputs.Chats, editMode]);
useEffect(() => {
if (refForm.current && editMode === 'json') {
refForm.current.setValues(inputs);
}
}, [editMode, inputs]);
const handleAddConfig = () => {
setEditingConfig({ name: '', url: '' });
setIsEdit(false);
setModalVisible(true);
setTimeout(() => {
if (modalFormRef.current) {
modalFormRef.current.setValues({ name: '', url: '' });
}
}, 100);
};
const handleEditConfig = (config) => {
setEditingConfig({ ...config });
setIsEdit(true);
setModalVisible(true);
setTimeout(() => {
if (modalFormRef.current) {
modalFormRef.current.setValues(config);
}
}, 100);
};
const handleDeleteConfig = (id) => {
const newConfigs = chatConfigs.filter((config) => config.id !== id);
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
showSuccess(t('删除成功'));
};
const handleModalOk = () => {
if (modalFormRef.current) {
modalFormRef.current
.validate()
.then((values) => {
// 检查名称是否重复
const isDuplicate = chatConfigs.some(
(config) =>
config.name === values.name &&
(!isEdit || config.id !== editingConfig.id)
);
if (isDuplicate) {
showError(t('聊天应用名称已存在,请使用其他名称'));
return;
}
if (isEdit) {
const newConfigs = chatConfigs.map((config) =>
config.id === editingConfig.id
? { ...editingConfig, name: values.name, url: values.url }
: config,
);
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
} else {
const maxId =
chatConfigs.length > 0
? Math.max(...chatConfigs.map((c) => c.id))
: -1;
const newConfig = {
id: maxId + 1,
name: values.name,
url: values.url,
};
const newConfigs = [...chatConfigs, newConfig];
setChatConfigs(newConfigs);
syncConfigsToJson(newConfigs);
}
setModalVisible(false);
setEditingConfig(null);
showSuccess(isEdit ? t('编辑成功') : t('添加成功'));
})
.catch((error) => {
console.error('Modal form validation error:', error);
});
}
};
const handleModalCancel = () => {
setModalVisible(false);
setEditingConfig(null);
};
const filteredConfigs = chatConfigs.filter(
(config) =>
!searchText ||
config.name.toLowerCase().includes(searchText.toLowerCase()),
);
const highlightKeywords = (text) => {
if (!text) return text;
const parts = text.split(/(\{address\}|\{key\})/g);
return parts.map((part, index) => {
if (part === '{address}') {
return (
<span key={index} style={{ color: '#0077cc', fontWeight: 600 }}>
{part}
</span>
);
} else if (part === '{key}') {
return (
<span key={index} style={{ color: '#ff6b35', fontWeight: 600 }}>
{part}
</span>
);
}
return part;
});
};
const columns = [
{
title: t('聊天应用名称'),
dataIndex: 'name',
key: 'name',
render: (text) => text || t('未命名'),
},
{
title: t('URL链接'),
dataIndex: 'url',
key: 'url',
render: (text) => (
<div style={{ maxWidth: 300, wordBreak: 'break-all' }}>
{highlightKeywords(text)}
</div>
),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Space>
<Button
type='primary'
icon={<IconEdit />}
size='small'
onClick={() => handleEditConfig(record)}
>
{t('编辑')}
</Button>
<Button
type='danger'
icon={<IconDelete />}
size='small'
onClick={() => handleDeleteConfig(record.id)}
>
{t('删除')}
</Button>
</Space>
),
},
];
return (
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Space vertical style={{ width: '100%' }}>
<Form.Section text={t('聊天设置')}>
<Banner
type='info'
@@ -120,34 +353,155 @@ export default function SettingsChats(props) {
'链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1',
)}
/>
<Form.TextArea
label={t('聊天配置')}
extraText={''}
placeholder={t('为一个 JSON 文本')}
field={'Chats'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
return verifyJSON(value);
},
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({
...inputs,
Chats: value,
})
}
/>
<Divider />
<div style={{ marginBottom: 16 }}>
<span style={{ marginRight: 16, fontWeight: 600 }}>
{t('编辑模式')}:
</span>
<RadioGroup
type='button'
value={editMode}
onChange={(e) => {
const newMode = e.target.value;
setEditMode(newMode);
// 确保模式切换时数据正确同步
setTimeout(() => {
if (newMode === 'json' && refForm.current) {
refForm.current.setValues(inputs);
}
}, 100);
}}
>
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='json'>{t('JSON编辑')}</Radio>
</RadioGroup>
</div>
{editMode === 'visual' ? (
<div>
<Space style={{ marginBottom: 16 }}>
<Button
type='primary'
icon={<IconPlus />}
onClick={handleAddConfig}
>
{t('添加聊天配置')}
</Button>
<Button
type='primary'
theme='solid'
icon={<IconSaveStroked />}
onClick={onSubmit}
>
{t('保存聊天设置')}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索聊天应用名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: 250 }}
showClear
/>
</Space>
<Table
columns={columns}
dataSource={filteredConfigs}
rowKey='id'
pagination={{
pageSize: 10,
showSizeChanger: false,
showQuickJumper: true,
showTotal: (total, range) =>
t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', {
total,
start: range[0],
end: range[1],
}),
}}
/>
</div>
) : (
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
>
<Form.TextArea
label={t('聊天配置')}
extraText={''}
placeholder={t('为一个 JSON 文本')}
field={'Chats'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
return verifyJSON(value);
},
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({
...inputs,
Chats: value,
})
}
/>
</Form>
)}
</Form.Section>
</Form>
<Space>
<Button onClick={onSubmit}>{t('保存聊天设置')}</Button>
{editMode === 'json' && (
<Space>
<Button
type='primary'
icon={<IconSaveStroked />}
onClick={onSubmit}
>
{t('保存聊天设置')}
</Button>
</Space>
)}
</Space>
<Modal
title={isEdit ? t('编辑聊天配置') : t('添加聊天配置')}
visible={modalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={600}
>
<Form getFormApi={(api) => (modalFormRef.current = api)}>
<Form.Input
field='name'
label={t('聊天应用名称')}
placeholder={t('请输入聊天应用名称')}
rules={[
{ required: true, message: t('请输入聊天应用名称') },
{ min: 1, message: t('名称不能为空') },
]}
/>
<Form.Input
field='url'
label={t('URL链接')}
placeholder={t('请输入完整的URL链接')}
rules={[{ required: true, message: t('请输入URL链接') }]}
/>
<Banner
type='info'
description={t(
'提示:链接中的{key}将被替换为API密钥{address}将被替换为服务器地址',
)}
style={{ marginTop: 16 }}
/>
</Form>
</Modal>
</Spin>
);
}

View File

@@ -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'}

View File

@@ -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),
})
}
/>

View File

@@ -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>

View File

@@ -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'