Compare commits

...

53 Commits

Author SHA1 Message Date
Calcium-Ion ed6ff0f267 Merge pull request #3329 from seefs001/fix/redirect-oauth
fix: redirect OAuth login in current page
2026-03-19 14:39:01 +08:00
Seefs d955a0c080 fix: redirect OAuth login in current page 2026-03-19 12:57:39 +08:00
CaIon d096a2e5b7 refactor: remove unused property from GEMINI_SETTING_EXAMPLE 2026-03-19 00:04:32 +08:00
CaIon d2fb485d34 fix: update translations in multiple languages for consistency and clarity 2026-03-19 00:04:32 +08:00
Seefs 04f5dd0206 Merge pull request #3293 from zhongyuanzhao-alt/ft-waffo-payment-zzy20260317
feat(waffo): Waffo payment gateway integration
2026-03-18 23:48:05 +08:00
Seefs ede0ad117b Merge pull request #3316 from Honghurumeng/main
fix: 修正 Codex free 账号用量显示到每周窗口
2026-03-18 21:34:29 +08:00
reed 5bb8fe6af5 fix: 修正 Codex free 账号用量显示到每周窗口 2026-03-18 19:11:07 +08:00
Seefs a1a92c1918 Merge pull request #3313 from ywandy/feat-search-ratelimit
feat: 支持通过环境变量配置搜索接口限流参数
2026-03-18 18:19:05 +08:00
gz1007 a4d1ed6da5 feat: 支持通过环境变量配置搜索接口限流参数 2026-03-18 17:50:23 +08:00
zhongyuan.zhao 669e596ff7 fix(waffo): filter waffo from generic payment selector, avoid duplicate buttons
When only Waffo was enabled, the generic payment method list showed a
"Waffo (Global Payment)" button calling preTopUp (epay flow) instead of
waffoTopUp, while the dedicated "Waffo 充值" section had the correct buttons.

Fix: filter waffo entries from generic list and hide the "选择支付方式"
column when no non-waffo methods exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:18:13 +08:00
zhongyuan.zhao 1daeac42ef fix(waffo): move Typography destructuring after all imports
ESM requires all import statements before other code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:15:06 +08:00
zhongyuan.zhao e70bfa2d57 fix(i18n): use consistent zh-TW term 管道管理 instead of 頻道管理
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:12:47 +08:00
zhongyuan.zhao bd09b47ef4 fix(waffo): use dedicated waffoMinTopUp for client-side validation
The waffoTopUp function was validating against the shared minTopUp
which could be set by epay/stripe when multiple gateways are enabled,
causing mismatch with backend's WaffoMinTopUp check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:01:47 +08:00
zhongyuan.zhao d595ef4990 fix(waffo): remove dead gatewayOrderId code that never persisted
The code read orderData.AcquiringOrderID but never assigned it to
any TopUp field before calling Update(), making the block a no-op.
Removed since GatewayOrderId storage is not needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:57:56 +08:00
zhongyuan.zhao 2270f63c00 fix(topup): add 'failed' status badge mapping in TopupHistoryModal
The backend defines TopUpStatusFailed = "failed" but the frontend
STATUS_CONFIG was missing this status, causing raw text display
instead of a styled danger badge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:55:09 +08:00
CaIon 8ed2ea6ec1 chore: exclude nightly tags from Docker image workflow triggers 2026-03-17 18:36:24 +08:00
zhongyuan.zhao 202a433f86 feat(waffo): Waffo payment gateway integration with configurable methods
- Add Waffo payment SDK integration (waffo-go v1.3.1)
- Backend: webhook handler, pay endpoint, order lock race-condition fix
- Settings: full Waffo config (API keys, sandbox/prod, currency, pay methods)
- Frontend: Waffo payment buttons in topup page, admin settings panel
- i18n: Waffo-related translations for en/fr/ja/ru/vi/zh-TW

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:04:58 +08:00
Calcium-Ion 620e066b39 Merge pull request #3287 from seefs001/pr-template
chore: refine PR template
2026-03-17 17:35:28 +08:00
Seefs 0246b20bf1 chore: refine PR template 2026-03-17 17:34:21 +08:00
Calcium-Ion 69551ab2de Merge pull request #3285 from seefs001/feature/param-override-log
feat: params override log
2026-03-17 17:22:39 +08:00
Seefs 8aa8b81e03 fix: original_model && upstream_model paramOverrideKeyAuditPaths 2026-03-17 17:00:01 +08:00
Seefs bc80477b1a feat: simplify param override audit UI and operation labels 2026-03-17 17:00:01 +08:00
Seefs 5db25f47f1 feat: add param override audit modal for usage logs 2026-03-17 17:00:01 +08:00
Seefs a4fd2246ba Merge pull request #3267 from feitianbubu/pr/eb2ed7806ae5f2348681ce9fcff3f3572f535919
feat: add logs content tooltip
2026-03-16 16:59:56 +08:00
feitianbubu 4e5e7b5828 feat: add logs content tooltip 2026-03-16 12:45:55 +08:00
Calcium-Ion 95738594b4 Merge pull request #3257 from seefs001/fix/passkey-verify
enhance channel key viewing
2026-03-15 01:27:06 +08:00
Seefs efab41c476 Merge pull request #3233 from KiGamji/round-remaining-balance
Round remaining balance
2026-03-15 00:58:33 +08:00
Seefs c77c82421e enhance channel key viewing 2026-03-15 00:23:13 +08:00
CaIon e4144d60f8 feat: update API proxy target and adjust component sizes in usage logs 2026-03-14 19:05:23 +08:00
CaIon 63f4595ef8 feat: refactor billing display mode change handler in ColumnSelectorModal 2026-03-14 17:05:44 +08:00
CaIon 5e856f0263 feat: remove unnecessary section for screenshots in bug report templates 2026-03-14 15:50:42 +08:00
CaIon b9f1d01e00 feat: update issue and feature request templates to include documentation links and submission checks 2026-03-14 15:48:50 +08:00
CaIon 5d620b9640 feat: update ratio label for user group handling in render component 2026-03-14 15:41:02 +08:00
CaIon 264bc963e0 feat: normalize number handling in model pricing editor #3246 2026-03-14 15:29:47 +08:00
KiGamji 9fbb782230 Round displayed remaining balance values 2026-03-12 19:35:32 +05:00
CaIon da8a52f50a feat: add conditional setting for HTTP headers in OpenRouter channel type 2026-03-12 19:05:30 +08:00
CaIon 9fdb0bc248 feat: comment out notify endpoint in relay router 2026-03-12 19:05:30 +08:00
Seefs 24ec27f844 Merge pull request #3221 from RedwindA/chore/updateModelList
chore: update model lists for frequently used channels
2026-03-12 15:13:03 +08:00
CaIon 5e9cc681f5 feat: update header title for OpenRouter channel type 2026-03-12 15:05:58 +08:00
RedwindA 7e68e1b36a chore: update model lists for frequently used channels 2026-03-11 23:39:18 +08:00
Calcium-Ion 45a59d32fb Merge pull request #3182 from seefs001/feature/params-override-beta-header-append
feat:support $keep_only_declared and deduped $append for header override
2026-03-10 02:03:02 +08:00
Seefs c1c07d063d refactor: optimize header override copy and JSON example dialog 2026-03-10 01:59:34 +08:00
CaIon 7fc39363d7 feat: enhance Claude request header handling with append functionality 2026-03-09 23:47:51 +08:00
Calcium-Ion 7b62694f60 Merge pull request #3147 from pigletfly/compose-add-networks
fix: add explicit docker-compose networks
2026-03-09 22:19:56 +08:00
Calcium-Ion 3b5d1daf39 Merge pull request #3148 from feitianbubu/pr/d8a25d36204224f8a4248b0ab3b03ba703796ea3
fix: kling risk fail return openAIVideo error
2026-03-09 22:19:04 +08:00
Seefs d087cc5025 feat:support $keep_only_declared and deduped $append for header token overrides 2026-03-09 00:12:53 +08:00
CaIon d67f446b66 feat: implement token key fetching and masking in API responses 2026-03-08 22:40:40 +08:00
Calcium-Ion ac72f90fc5 Merge pull request #3166 from somnifex/main
为渠道参数覆盖可视化规则提供拖拽排序支持
2026-03-07 15:02:35 +08:00
somnifex 3f662e4bc0 feat: add drag-and-drop functionality for operation reordering in ParamOverrideEditorModal 2026-03-07 14:10:06 +08:00
CaIon 287af7ebee feat: integrate site display type into pricing components
Add siteDisplayType prop across various pricing components to conditionally render pricing information based on the selected display type. This update enhances the user experience by ensuring that pricing details are accurately represented according to the chosen display mode, particularly for token-based views.
2026-03-07 00:23:36 +08:00
CaIon aa89ea2db5 feat: add billing display mode selection and update pricing rendering
Introduce a billing display mode feature allowing users to toggle between price and ratio views. Update relevant components and hooks to support this new functionality, ensuring consistent pricing information is displayed across the application.
2026-03-06 23:35:17 +08:00
feitianbubu 2e20ede2a0 fix: kling risk fail return openAIVideo error 2026-03-06 16:32:52 +08:00
pigletfly 9cfaa68e5a fix(compose): Add explicit bridge network 2026-03-06 15:44:47 +08:00
95 changed files with 8029 additions and 3253 deletions
+14 -6
View File
@@ -7,14 +7,23 @@ assignees: ''
---
**例行检查**
## 提交前必读(请勿删除本节)
- 文档:https://docs.newapi.ai/
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
**您当前的 newapi 版本**
请填写,例如:`v1.0.0`
**提交确认**
[//]: # (方框内删除已有的空格,填 x 号)
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本
+ [ ] 我已完整查看过项目 README,尤其是常见问题部分
+ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
+ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,尤其是常见问题部分
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
**问题描述**
@@ -23,4 +32,3 @@ assignees: ''
**预期结果**
**相关截图**
如果没有的话,请删除此节。
+15 -7
View File
@@ -7,14 +7,23 @@ assignees: ''
---
**Routine Checks**
## Read This First (Do Not Remove This Section)
- Docs: https://docs.newapi.ai/
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
**Your current newapi version**
Please fill this in, for example: `v1.0.0`
**Submission 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**
+ [ ] I have confirmed there are no similar issues
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, especially the FAQ section
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
**Issue Description**
@@ -23,4 +32,3 @@ assignees: ''
**Expected Result**
**Related Screenshots**
If none, please delete this section.
+6 -3
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 项目群聊
url: https://private-user-images.githubusercontent.com/61247483/283011625-de536a8a-0161-47a7-a0a2-66ef6de81266.jpeg?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTEiLCJleHAiOjE3MDIyMjQzOTAsIm5iZiI6MTcwMjIyNDA5MCwicGF0aCI6Ii82MTI0NzQ4My8yODMwMTE2MjUtZGU1MzZhOGEtMDE2MS00N2E3LWEwYTItNjZlZjZkZTgxMjY2LmpwZWc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBSVdOSllBWDRDU1ZFSDUzQSUyRjIwMjMxMjEwJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDIzMTIxMFQxNjAxMzBaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT02MGIxYmM3ZDQyYzBkOTA2ZTYyYmVmMzQ1NjY4NjM1YjY0NTUzNTM5NjE1NDZkYTIzODdhYTk4ZjZjODJmYzY2JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.TJ8CTfOSwR0-CHS1KLfomqgL0e4YH1luy8lSLrkv5Zg
about: QQ 群:629454374
- name: 使用文档 / Documentation
url: https://docs.newapi.ai/
about: 提交 issue 前请先查阅文档,确认现有说明无法解决你的问题。
- name: 使用问题 / Usage Questions
url: https://deepwiki.com/QuantumNous/new-api
about: 使用、配置、接入等问题请优先在 DeepWiki 查询或提问。
+14 -5
View File
@@ -7,14 +7,23 @@ assignees: ''
---
**例行检查**
## 提交前必读(请勿删除本节)
- 文档:https://docs.newapi.ai/
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
**您当前的 newapi 版本**
请填写,例如:`v1.0.0`
**提交确认**
[//]: # (方框内删除已有的空格,填 x 号)
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本
+ [ ] 我已完整查看过项目 README,已确定现有版本无法满足需求
+ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
+ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,已确定现有版本无法满足需求
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
**功能描述**
+15 -7
View File
@@ -7,16 +7,24 @@ assignees: ''
---
**Routine Checks**
## Read This First (Do Not Remove This Section)
- Docs: https://docs.newapi.ai/
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
**Your current newapi version**
Please fill this in, for example: `v1.0.0`
**Submission 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**
+ [ ] I have confirmed there are no similar issues
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, and confirmed the current version cannot meet my needs
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
**Feature Description**
**Use Case**
@@ -1,15 +1,29 @@
### PR 类型
# ⚠️ 提交警告 / PR Warning
> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。
- [ ] Bug 修复
- [ ] 新功能
- [ ] 文档更新
- [ ] 其他
---
### PR 是否包含破坏性更新?
## 💡 沟通提示 / Pre-submission
> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。
- [ ]
- [ ]
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话)
### PR 描述
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix)
- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
**请在下方详细描述您的 PR,包括目的、实现细节等。**
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。
- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
+1
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- '*'
- '!nightly*'
workflow_dispatch:
inputs:
tag:
+2
View File
@@ -1,6 +1,7 @@
.idea
.vscode
.zed
.history
upload
*.exe
*.db
@@ -20,6 +21,7 @@ tiktoken_cache
.cache
web/bun.lock
plans
.claude
electron/node_modules
electron/dist
+2
View File
@@ -177,6 +177,7 @@ var (
DownloadRateLimitDuration int64 = 60
// Per-user search rate limit (applies after authentication, keyed by user ID)
SearchRateLimitEnable = true
SearchRateLimitNum = 10
SearchRateLimitDuration int64 = 60
)
@@ -211,5 +212,6 @@ const (
const (
TopUpStatusPending = "pending"
TopUpStatusSuccess = "success"
TopUpStatusFailed = "failed"
TopUpStatusExpired = "expired"
)
+4
View File
@@ -120,6 +120,10 @@ func InitEnv() {
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
SearchRateLimitEnable = GetEnvOrDefaultBool("SEARCH_RATE_LIMIT_ENABLE", true)
SearchRateLimitNum = GetEnvOrDefault("SEARCH_RATE_LIMIT", 10)
SearchRateLimitDuration = int64(GetEnvOrDefault("SEARCH_RATE_LIMIT_DURATION", 60))
initConstantEnv()
}
+16
View File
@@ -0,0 +1,16 @@
package constant
// WaffoPayMethod defines the display and API parameter mapping for Waffo payment methods.
type WaffoPayMethod struct {
Name string `json:"name"` // Frontend display name
Icon string `json:"icon"` // Frontend icon identifier: credit-card, apple, google
PayMethodType string `json:"payMethodType"` // Waffo API PayMethodType, can be comma-separated
PayMethodName string `json:"payMethodName"` // Waffo API PayMethodName, empty means auto-select by Waffo checkout
}
// DefaultWaffoPayMethods is the default list of supported payment methods.
var DefaultWaffoPayMethods = []WaffoPayMethod{
{Name: "Card", Icon: "/pay-card.png", PayMethodType: "CREDITCARD,DEBITCARD", PayMethodName: ""},
{Name: "Apple Pay", Icon: "/pay-apple.png", PayMethodType: "APPLEPAY", PayMethodName: "APPLEPAY"},
{Name: "Google Pay", Icon: "/pay-google.png", PayMethodType: "GOOGLEPAY", PayMethodName: "GOOGLEPAY"},
}
+9
View File
@@ -470,6 +470,15 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
session := sessions.Default(c)
// Mark passkey as ready; /api/verify will convert this into the final secure verification session.
session.Set(PasskeyReadySessionKey, time.Now().Unix())
session.Delete(SecureVerificationSessionKey)
if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
+41 -92
View File
@@ -7,18 +7,19 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key
// SecureVerificationSessionKey means the user has fully passed secure verification.
SecureVerificationSessionKey = "secure_verified_at"
// PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification.
PasskeyReadySessionKey = "secure_passkey_ready_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
// PasskeyReadyTimeout passkey ready 标记有效期(秒)
PasskeyReadyTimeout = 60
)
type UniversalVerifyRequest struct {
@@ -76,6 +77,7 @@ func UniversalVerify(c *gin.Context) {
// 根据验证方式进行验证
var verified bool
var verifyMethod string
var err error
switch req.Method {
case "2fa":
@@ -95,10 +97,16 @@ func UniversalVerify(c *gin.Context) {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish
// 这里只是验证 Passkey 验证流程是否已经完成
// 实际上,前端应该先调用这两个接口,然后再调用本接口
verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成
// Passkey branch only trusts the short-lived marker written by PasskeyVerifyFinish.
verified, err = consumePasskeyReady(c)
if err != nil {
common.ApiError(c, fmt.Errorf("Passkey 验证状态异常: %v", err))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("请先完成 Passkey 验证"))
return
}
verifyMethod = "Passkey"
default:
@@ -112,10 +120,8 @@ func UniversalVerify(c *gin.Context) {
}
// 验证成功,在 session 中记录时间戳
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
if err := session.Save(); err != nil {
now, err := setSecureVerificationSession(c)
if err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return
}
@@ -133,94 +139,37 @@ func UniversalVerify(c *gin.Context) {
})
}
// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session
// 这是一个辅助函数,供 PasskeyVerifyFinish 调用
func PasskeyVerifyAndSetSession(c *gin.Context) {
func setSecureVerificationSession(c *gin.Context) (int64, error) {
session := sessions.Default(c)
session.Delete(PasskeyReadySessionKey)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
_ = session.Save()
if err := session.Save(); err != nil {
return 0, err
}
return now, nil
}
// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程
// 整合了 begin 和 finish 流程
func PasskeyVerifyForSecure(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
func consumePasskeyReady(c *gin.Context) (bool, error) {
session := sessions.Default(c)
readyAtRaw := session.Get(PasskeyReadySessionKey)
if readyAtRaw == nil {
return false, nil
}
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
readyAt, ok := readyAtRaw.(int64)
if !ok {
session.Delete(PasskeyReadySessionKey)
_ = session.Save()
return false, fmt.Errorf("无效的 Passkey 验证状态")
}
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
session.Delete(PasskeyReadySessionKey)
if err := session.Save(); err != nil {
return false, err
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
// Expired ready markers cannot be reused.
if time.Now().Unix()-readyAt >= PasskeyReadyTimeout {
return false, nil
}
credential, err := model.GetPasskeyByUserID(userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
if err != nil {
common.ApiError(c, err)
return
}
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
// 更新凭证的最后使用时间
now := time.Now()
credential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(credential); err != nil {
common.ApiError(c, err)
return
}
// 验证成功,设置 session
PasskeyVerifyAndSetSession(c)
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
"data": gin.H{
"verified": true,
"expires_at": time.Now().Unix() + SecureVerificationTimeout,
},
})
return true, nil
}
+37 -12
View File
@@ -14,6 +14,23 @@ import (
"github.com/gin-gonic/gin"
)
func buildMaskedTokenResponse(token *model.Token) *model.Token {
if token == nil {
return nil
}
maskedToken := *token
maskedToken.Key = token.GetMaskedKey()
return &maskedToken
}
func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
maskedTokens := make([]*model.Token, 0, len(tokens))
for _, token := range tokens {
maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
}
return maskedTokens
}
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
@@ -24,9 +41,8 @@ func GetAllTokens(c *gin.Context) {
}
total, _ := model.CountUserTokens(userId)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(tokens)
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
return
}
func SearchTokens(c *gin.Context) {
@@ -42,9 +58,8 @@ func SearchTokens(c *gin.Context) {
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(tokens)
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
return
}
func GetToken(c *gin.Context) {
@@ -59,12 +74,24 @@ func GetToken(c *gin.Context) {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": token,
common.ApiSuccess(c, buildMaskedTokenResponse(token))
}
func GetTokenKey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
if err != nil {
common.ApiError(c, err)
return
}
token, err := model.GetTokenByIds(id, userId)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"key": token.GetFullKey(),
})
return
}
func GetTokenStatus(c *gin.Context) {
@@ -204,7 +231,6 @@ func AddToken(c *gin.Context) {
"success": true,
"message": "",
})
return
}
func DeleteToken(c *gin.Context) {
@@ -219,7 +245,6 @@ func DeleteToken(c *gin.Context) {
"success": true,
"message": "",
})
return
}
func UpdateToken(c *gin.Context) {
@@ -283,7 +308,7 @@ func UpdateToken(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": cleanToken,
"data": buildMaskedTokenResponse(cleanToken),
})
}
+275
View File
@@ -0,0 +1,275 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type tokenAPIResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
type tokenPageResponse struct {
Items []tokenResponseItem `json:"items"`
}
type tokenResponseItem struct {
ID int `json:"id"`
Name string `json:"name"`
Key string `json:"key"`
Status int `json:"status"`
}
type tokenKeyResponse struct {
Key string `json:"key"`
}
func setupTokenControllerTestDB(t *testing.T) *gorm.DB {
t.Helper()
gin.SetMode(gin.TestMode)
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
model.DB = db
model.LOG_DB = db
if err := db.AutoMigrate(&model.Token{}); err != nil {
t.Fatalf("failed to migrate token table: %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func seedToken(t *testing.T, db *gorm.DB, userID int, name string, rawKey string) *model.Token {
t.Helper()
token := &model.Token{
UserId: userID,
Name: name,
Key: rawKey,
Status: common.TokenStatusEnabled,
CreatedTime: 1,
AccessedTime: 1,
ExpiredTime: -1,
RemainQuota: 100,
UnlimitedQuota: true,
Group: "default",
}
if err := db.Create(token).Error; err != nil {
t.Fatalf("failed to create token: %v", err)
}
return token
}
func newAuthenticatedContext(t *testing.T, method string, target string, body any, userID int) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
var requestBody *bytes.Reader
if body != nil {
payload, err := common.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
requestBody = bytes.NewReader(payload)
} else {
requestBody = bytes.NewReader(nil)
}
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(method, target, requestBody)
if body != nil {
ctx.Request.Header.Set("Content-Type", "application/json")
}
ctx.Set("id", userID)
return ctx, recorder
}
func decodeAPIResponse(t *testing.T, recorder *httptest.ResponseRecorder) tokenAPIResponse {
t.Helper()
var response tokenAPIResponse
if err := common.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to decode api response: %v", err)
}
return response
}
func TestGetAllTokensMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "list-token", "abcd1234efgh5678")
seedToken(t, db, 2, "other-user-token", "zzzz1234yyyy5678")
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?p=1&size=10", nil, 1)
GetAllTokens(ctx)
response := decodeAPIResponse(t, recorder)
if !response.Success {
t.Fatalf("expected success response, got message: %s", response.Message)
}
var page tokenPageResponse
if err := common.Unmarshal(response.Data, &page); err != nil {
t.Fatalf("failed to decode token page response: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("expected exactly one token, got %d", len(page.Items))
}
if page.Items[0].Key != token.GetMaskedKey() {
t.Fatalf("expected masked key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
}
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("list response leaked raw token key: %s", recorder.Body.String())
}
}
func TestSearchTokensMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "searchable-token", "ijkl1234mnop5678")
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=searchable-token&p=1&size=10", nil, 1)
SearchTokens(ctx)
response := decodeAPIResponse(t, recorder)
if !response.Success {
t.Fatalf("expected success response, got message: %s", response.Message)
}
var page tokenPageResponse
if err := common.Unmarshal(response.Data, &page); err != nil {
t.Fatalf("failed to decode search response: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("expected exactly one search result, got %d", len(page.Items))
}
if page.Items[0].Key != token.GetMaskedKey() {
t.Fatalf("expected masked search key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
}
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("search response leaked raw token key: %s", recorder.Body.String())
}
}
func TestGetTokenMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "detail-token", "qrst1234uvwx5678")
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(token.Id), nil, 1)
ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
GetToken(ctx)
response := decodeAPIResponse(t, recorder)
if !response.Success {
t.Fatalf("expected success response, got message: %s", response.Message)
}
var detail tokenResponseItem
if err := common.Unmarshal(response.Data, &detail); err != nil {
t.Fatalf("failed to decode token detail response: %v", err)
}
if detail.Key != token.GetMaskedKey() {
t.Fatalf("expected masked detail key %q, got %q", token.GetMaskedKey(), detail.Key)
}
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("detail response leaked raw token key: %s", recorder.Body.String())
}
}
func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678")
body := map[string]any{
"id": token.Id,
"name": "updated-token",
"expired_time": -1,
"remain_quota": 100,
"unlimited_quota": true,
"model_limits_enabled": false,
"model_limits": "",
"group": "default",
"cross_group_retry": false,
}
ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1)
UpdateToken(ctx)
response := decodeAPIResponse(t, recorder)
if !response.Success {
t.Fatalf("expected success response, got message: %s", response.Message)
}
var detail tokenResponseItem
if err := common.Unmarshal(response.Data, &detail); err != nil {
t.Fatalf("failed to decode token update response: %v", err)
}
if detail.Key != token.GetMaskedKey() {
t.Fatalf("expected masked update key %q, got %q", token.GetMaskedKey(), detail.Key)
}
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("update response leaked raw token key: %s", recorder.Body.String())
}
}
func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "owned-token", "owner1234token5678")
authorizedCtx, authorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 1)
authorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
GetTokenKey(authorizedCtx)
authorizedResponse := decodeAPIResponse(t, authorizedRecorder)
if !authorizedResponse.Success {
t.Fatalf("expected authorized key fetch to succeed, got message: %s", authorizedResponse.Message)
}
var keyData tokenKeyResponse
if err := common.Unmarshal(authorizedResponse.Data, &keyData); err != nil {
t.Fatalf("failed to decode token key response: %v", err)
}
if keyData.Key != token.GetFullKey() {
t.Fatalf("expected full key %q, got %q", token.GetFullKey(), keyData.Key)
}
unauthorizedCtx, unauthorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 2)
unauthorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
GetTokenKey(unauthorizedCtx)
unauthorizedResponse := decodeAPIResponse(t, unauthorizedRecorder)
if unauthorizedResponse.Success {
t.Fatalf("expected unauthorized key fetch to fail")
}
if strings.Contains(unauthorizedRecorder.Body.String(), token.Key) {
t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String())
}
}
+68 -14
View File
@@ -48,14 +48,52 @@ func GetTopUpInfo(c *gin.Context) {
}
}
// 如果启用了 Waffo 支付,添加到支付方法列表
enableWaffo := setting.WaffoEnabled &&
((!setting.WaffoSandbox &&
setting.WaffoApiKey != "" &&
setting.WaffoPrivateKey != "" &&
setting.WaffoPublicCert != "") ||
(setting.WaffoSandbox &&
setting.WaffoSandboxApiKey != "" &&
setting.WaffoSandboxPrivateKey != "" &&
setting.WaffoSandboxPublicCert != ""))
if enableWaffo {
hasWaffo := false
for _, method := range payMethods {
if method["type"] == "waffo" {
hasWaffo = true
break
}
}
if !hasWaffo {
waffoMethod := map[string]string{
"name": "Waffo (Global Payment)",
"type": "waffo",
"color": "rgba(var(--semi-blue-5), 1)",
"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
}
payMethods = append(payMethods, waffoMethod)
}
}
data := gin.H{
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
"creem_products": setting.CreemProducts,
"enable_waffo_topup": enableWaffo,
"waffo_pay_methods": func() interface{} {
if enableWaffo {
return setting.GetWaffoPayMethods()
}
return nil
}(),
"creem_products": setting.CreemProducts,
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"waffo_min_topup": setting.WaffoMinTopUp,
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
}
@@ -204,27 +242,42 @@ func RequestEpay(c *gin.Context) {
var orderLocks sync.Map
var createLock sync.Mutex
// refCountedMutex 带引用计数的互斥锁,确保最后一个使用者才从 map 中删除
type refCountedMutex struct {
mu sync.Mutex
refCount int
}
// LockOrder 尝试对给定订单号加锁
func LockOrder(tradeNo string) {
lock, ok := orderLocks.Load(tradeNo)
if !ok {
createLock.Lock()
defer createLock.Unlock()
lock, ok = orderLocks.Load(tradeNo)
if !ok {
lock = new(sync.Mutex)
orderLocks.Store(tradeNo, lock)
}
createLock.Lock()
var rcm *refCountedMutex
if v, ok := orderLocks.Load(tradeNo); ok {
rcm = v.(*refCountedMutex)
} else {
rcm = &refCountedMutex{}
orderLocks.Store(tradeNo, rcm)
}
lock.(*sync.Mutex).Lock()
rcm.refCount++
createLock.Unlock()
rcm.mu.Lock()
}
// UnlockOrder 释放给定订单号的锁
func UnlockOrder(tradeNo string) {
lock, ok := orderLocks.Load(tradeNo)
if ok {
lock.(*sync.Mutex).Unlock()
v, ok := orderLocks.Load(tradeNo)
if !ok {
return
}
rcm := v.(*refCountedMutex)
rcm.mu.Unlock()
createLock.Lock()
rcm.refCount--
if rcm.refCount == 0 {
orderLocks.Delete(tradeNo)
}
createLock.Unlock()
}
func EpayNotify(c *gin.Context) {
@@ -410,3 +463,4 @@ func AdminCompleteTopUp(c *gin.Context) {
}
common.ApiSuccess(c, nil)
}
+380
View File
@@ -0,0 +1,380 @@
package controller
import (
"fmt"
"io"
"log"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/thanhpk/randstr"
waffo "github.com/waffo-com/waffo-go"
"github.com/waffo-com/waffo-go/config"
"github.com/waffo-com/waffo-go/core"
"github.com/waffo-com/waffo-go/types/order"
)
func getWaffoSDK() (*waffo.Waffo, error) {
env := config.Sandbox
apiKey := setting.WaffoSandboxApiKey
privateKey := setting.WaffoSandboxPrivateKey
publicKey := setting.WaffoSandboxPublicCert
if !setting.WaffoSandbox {
env = config.Production
apiKey = setting.WaffoApiKey
privateKey = setting.WaffoPrivateKey
publicKey = setting.WaffoPublicCert
}
builder := config.NewConfigBuilder().
APIKey(apiKey).
PrivateKey(privateKey).
WaffoPublicKey(publicKey).
Environment(env)
if setting.WaffoMerchantId != "" {
builder = builder.MerchantID(setting.WaffoMerchantId)
}
cfg, err := builder.Build()
if err != nil {
return nil, err
}
return waffo.New(cfg), nil
}
func getWaffoUserEmail(user *model.User) string {
return fmt.Sprintf("%d@examples.com", user.Id)
}
func getWaffoCurrency() string {
if setting.WaffoCurrency != "" {
return setting.WaffoCurrency
}
return "USD"
}
// zeroDecimalCurrencies 零小数位币种,金额不能带小数点
var zeroDecimalCurrencies = map[string]bool{
"IDR": true, "JPY": true, "KRW": true, "VND": true,
}
func formatWaffoAmount(amount float64, currency string) string {
if zeroDecimalCurrencies[currency] {
return fmt.Sprintf("%.0f", amount)
}
return fmt.Sprintf("%.2f", amount)
}
// getWaffoPayMoney converts the user-facing amount to USD for Waffo payment.
// Waffo only accepts USD, so this function handles the conversion from different
// display types (USD/CNY/TOKENS) to the actual USD amount to charge.
func getWaffoPayMoney(amount float64, group string) float64 {
originalAmount := amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit
}
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
if ds > 0 {
discount = ds
}
}
return amount * setting.WaffoUnitPrice * topupGroupRatio * discount
}
type WaffoPayRequest struct {
Amount int64 `json:"amount"`
PayMethodIndex *int `json:"pay_method_index"` // 服务端支付方式列表的索引,nil 表示由 Waffo 自动选择
PayMethodType string `json:"pay_method_type"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
PayMethodName string `json:"pay_method_name"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
}
// RequestWaffoPay 创建 Waffo 支付订单
func RequestWaffoPay(c *gin.Context) {
if !setting.WaffoEnabled {
c.JSON(200, gin.H{"message": "error", "data": "Waffo 支付未启用"})
return
}
var req WaffoPayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
return
}
waffoMinTopup := int64(setting.WaffoMinTopUp)
if req.Amount < waffoMinTopup {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
return
}
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(200, gin.H{"message": "error", "data": "用户不存在"})
return
}
// 从服务端配置查找支付方式,客户端只传索引或旧字段
var resolvedPayMethodType, resolvedPayMethodName string
methods := setting.GetWaffoPayMethods()
if req.PayMethodIndex != nil {
// 新协议:按索引查找
idx := *req.PayMethodIndex
if idx < 0 || idx >= len(methods) {
log.Printf("Waffo 无效的支付方式索引: %d, UserId=%d, 可用范围: [0, %d)", idx, id, len(methods))
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
return
}
resolvedPayMethodType = methods[idx].PayMethodType
resolvedPayMethodName = methods[idx].PayMethodName
} else if req.PayMethodType != "" {
// 兼容旧前端:验证客户端传的值在服务端列表中
valid := false
for _, m := range methods {
if m.PayMethodType == req.PayMethodType && m.PayMethodName == req.PayMethodName {
valid = true
resolvedPayMethodType = m.PayMethodType
resolvedPayMethodName = m.PayMethodName
break
}
}
if !valid {
log.Printf("Waffo 无效的支付方式: PayMethodType=%s, PayMethodName=%s, UserId=%d", req.PayMethodType, req.PayMethodName, id)
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
return
}
}
// resolvedPayMethodType/Name 为空时,Waffo 自动选择支付方式
group, _ := model.GetUserGroup(id, true)
payMoney := getWaffoPayMoney(float64(req.Amount), group)
if payMoney < 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
return
}
// 生成唯一订单号,paymentRequestId 与 merchantOrderId 保持一致,简化追踪
merchantOrderId := fmt.Sprintf("WAFFO-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
paymentRequestId := merchantOrderId
// Token 模式下归一化 Amount(存等价美元/CNY 数量,避免 RechargeWaffo 双重放大)
amount := req.Amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = int64(float64(req.Amount) / common.QuotaPerUnit)
if amount < 1 {
amount = 1
}
}
// 创建本地订单
topUp := &model.TopUp{
UserId: id,
Amount: amount,
Money: payMoney,
TradeNo: merchantOrderId,
PaymentMethod: "waffo",
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := topUp.Insert(); err != nil {
log.Printf("Waffo 创建本地订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
return
}
sdk, err := getWaffoSDK()
if err != nil {
log.Printf("Waffo SDK 初始化失败: %v", err)
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "支付配置错误"})
return
}
callbackAddr := service.GetCallbackAddress()
notifyUrl := callbackAddr + "/api/waffo/webhook"
if setting.WaffoNotifyUrl != "" {
notifyUrl = setting.WaffoNotifyUrl
}
returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true"
if setting.WaffoReturnUrl != "" {
returnUrl = setting.WaffoReturnUrl
}
currency := getWaffoCurrency()
createParams := &order.CreateOrderParams{
PaymentRequestID: paymentRequestId,
MerchantOrderID: merchantOrderId,
OrderAmount: formatWaffoAmount(payMoney, currency),
OrderCurrency: currency,
OrderDescription: fmt.Sprintf("Recharge %d credits", req.Amount),
OrderRequestedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
NotifyURL: notifyUrl,
MerchantInfo: &order.MerchantInfo{
MerchantID: setting.WaffoMerchantId,
},
UserInfo: &order.UserInfo{
UserID: strconv.Itoa(user.Id),
UserEmail: getWaffoUserEmail(user),
UserTerminal: "WEB",
},
PaymentInfo: &order.PaymentInfo{
ProductName: "ONE_TIME_PAYMENT",
PayMethodType: resolvedPayMethodType,
PayMethodName: resolvedPayMethodName,
},
SuccessRedirectURL: returnUrl,
FailedRedirectURL: returnUrl,
}
resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
if err != nil {
log.Printf("Waffo 创建订单失败: %v", err)
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
if !resp.IsSuccess() {
log.Printf("Waffo 创建订单业务失败: [%s] %s, 完整响应: %+v", resp.Code, resp.Message, resp)
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
orderData := resp.GetData()
log.Printf("Waffo 订单创建成功 - 用户: %d, 订单: %s, 金额: %.2f", id, merchantOrderId, payMoney)
paymentUrl := orderData.FetchRedirectURL()
if paymentUrl == "" {
paymentUrl = orderData.OrderAction
}
c.JSON(200, gin.H{
"message": "success",
"data": gin.H{
"payment_url": paymentUrl,
"order_id": merchantOrderId,
},
})
}
// webhookPayloadWithSubInfo 扩展 PAYMENT_NOTIFICATION,包含 SDK 未定义的 subscriptionInfo 字段
type webhookPayloadWithSubInfo struct {
EventType string `json:"eventType"`
Result struct {
core.PaymentNotificationResult
SubscriptionInfo *webhookSubscriptionInfo `json:"subscriptionInfo,omitempty"`
} `json:"result"`
}
type webhookSubscriptionInfo struct {
Period string `json:"period,omitempty"`
MerchantRequest string `json:"merchantRequest,omitempty"`
SubscriptionID string `json:"subscriptionId,omitempty"`
SubscriptionRequest string `json:"subscriptionRequest,omitempty"`
}
// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
func WaffoWebhook(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("Waffo Webhook 读取 body 失败: %v", err)
c.AbortWithStatus(http.StatusBadRequest)
return
}
sdk, err := getWaffoSDK()
if err != nil {
log.Printf("Waffo Webhook SDK 初始化失败: %v", err)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
wh := sdk.Webhook()
bodyStr := string(bodyBytes)
signature := c.GetHeader("X-SIGNATURE")
// 验证请求签名
if !wh.VerifySignature(bodyStr, signature) {
log.Printf("Waffo webhook 签名验证失败")
c.AbortWithStatus(http.StatusBadRequest)
return
}
var event core.WebhookEvent
if err := common.Unmarshal(bodyBytes, &event); err != nil {
log.Printf("Waffo Webhook 解析失败: %v", err)
sendWaffoWebhookResponse(c, wh, false, "invalid payload")
return
}
switch event.EventType {
case core.EventPayment:
// 解析为扩展类型,区分普通支付和订阅支付
var payload webhookPayloadWithSubInfo
if err := common.Unmarshal(bodyBytes, &payload); err != nil {
sendWaffoWebhookResponse(c, wh, false, "invalid payment payload")
return
}
log.Printf("Waffo Webhook - EventType: %s, MerchantOrderId: %s, OrderStatus: %s",
event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus)
handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
default:
log.Printf("Waffo Webhook 未知事件: %s", event.EventType)
sendWaffoWebhookResponse(c, wh, true, "")
}
}
// handleWaffoPayment 处理支付完成通知
func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) {
if result.OrderStatus != "PAY_SUCCESS" {
log.Printf("Waffo 订单状态非成功: %s, 订单: %s", result.OrderStatus, result.MerchantOrderID)
// 终态失败订单标记为 failed,避免永远停在 pending
if result.MerchantOrderID != "" {
if topUp := model.GetTopUpByTradeNo(result.MerchantOrderID); topUp != nil &&
topUp.Status == common.TopUpStatusPending {
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
}
}
sendWaffoWebhookResponse(c, wh, true, "")
return
}
merchantOrderId := result.MerchantOrderID
LockOrder(merchantOrderId)
defer UnlockOrder(merchantOrderId)
if err := model.RechargeWaffo(merchantOrderId); err != nil {
log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
sendWaffoWebhookResponse(c, wh, false, err.Error())
return
}
log.Printf("Waffo 充值成功 - 订单: %s", merchantOrderId)
sendWaffoWebhookResponse(c, wh, true, "")
}
// sendWaffoWebhookResponse 发送签名响应
func sendWaffoWebhookResponse(c *gin.Context, wh *core.WebhookHandler, success bool, msg string) {
var body, sig string
if success {
body, sig = wh.BuildSuccessResponse()
} else {
body, sig = wh.BuildFailedResponse(msg)
}
c.Header("X-SIGNATURE", sig)
c.Data(http.StatusOK, "application/json", []byte(body))
}
+12
View File
@@ -43,6 +43,8 @@ services:
- redis
- postgres
# - mysql # Uncomment if using MySQL
networks:
- new-api-network
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
interval: 30s
@@ -53,6 +55,8 @@ services:
image: redis:latest
container_name: redis
restart: always
networks:
- new-api-network
postgres:
image: postgres:15
@@ -64,6 +68,8 @@ services:
POSTGRES_DB: new-api
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- new-api-network
# ports:
# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker
@@ -76,9 +82,15 @@ services:
# MYSQL_DATABASE: new-api
# volumes:
# - mysql_data:/var/lib/mysql
# networks:
# - new-api-network
# ports:
# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker
volumes:
pg_data:
# mysql_data:
networks:
new-api-network:
driver: bridge
+1 -1
View File
@@ -46,6 +46,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.6.2
github.com/waffo-com/waffo-go v1.3.1
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.45.0
golang.org/x/image v0.23.0
@@ -120,7 +121,6 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/samber/go-singleflightx v0.3.2 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
+14 -33
View File
@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW515g=
@@ -10,34 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fvIS1iAP+DcRv5VJtgixbEYDsI5g=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -58,7 +44,6 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -132,12 +117,13 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -186,8 +172,6 @@ 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/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -245,7 +229,6 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -262,8 +245,9 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/samber/go-singleflightx v0.3.2 h1:jXbUU0fvis8Fdv4HGONboX5WdEZcYLoBEcKiE+ITCyQ=
github.com/samber/go-singleflightx v0.3.2/go.mod h1:X2BR+oheHIYc73PvxRMlcASg6KYYTQyUYpdVU7t/ux4=
github.com/samber/hot v0.11.0 h1:JhV9hk8SmZIqB0To8OyCzPubvszkuoSXWx/7FCEGO+Q=
@@ -320,6 +304,8 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw=
github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -330,6 +316,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=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
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=
@@ -339,14 +327,12 @@ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/y
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -367,19 +353,14 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+4 -1
View File
@@ -196,7 +196,10 @@ func userRedisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, key
}
// SearchRateLimit returns a per-user rate limiter for search endpoints.
// 10 requests per 60 seconds per user (by user ID, not IP).
// Configurable via SEARCH_RATE_LIMIT_ENABLE / SEARCH_RATE_LIMIT / SEARCH_RATE_LIMIT_DURATION.
func SearchRateLimit() func(c *gin.Context) {
if !common.SearchRateLimitEnable {
return defNext
}
return userRateLimitFactory(common.SearchRateLimitNum, common.SearchRateLimitDuration, "SR")
}
+50
View File
@@ -89,6 +89,22 @@ func InitOptionMap() {
common.OptionMap["CreemProducts"] = setting.CreemProducts
common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode)
common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret
common.OptionMap["WaffoEnabled"] = strconv.FormatBool(setting.WaffoEnabled)
common.OptionMap["WaffoApiKey"] = setting.WaffoApiKey
common.OptionMap["WaffoPrivateKey"] = setting.WaffoPrivateKey
common.OptionMap["WaffoPublicCert"] = setting.WaffoPublicCert
common.OptionMap["WaffoSandboxPublicCert"] = setting.WaffoSandboxPublicCert
common.OptionMap["WaffoSandboxApiKey"] = setting.WaffoSandboxApiKey
common.OptionMap["WaffoSandboxPrivateKey"] = setting.WaffoSandboxPrivateKey
common.OptionMap["WaffoSandbox"] = strconv.FormatBool(setting.WaffoSandbox)
common.OptionMap["WaffoMerchantId"] = setting.WaffoMerchantId
common.OptionMap["WaffoNotifyUrl"] = setting.WaffoNotifyUrl
common.OptionMap["WaffoReturnUrl"] = setting.WaffoReturnUrl
common.OptionMap["WaffoSubscriptionReturnUrl"] = setting.WaffoSubscriptionReturnUrl
common.OptionMap["WaffoCurrency"] = setting.WaffoCurrency
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -358,6 +374,36 @@ func updateOptionMap(key string, value string) (err error) {
setting.CreemTestMode = value == "true"
case "CreemWebhookSecret":
setting.CreemWebhookSecret = value
case "WaffoEnabled":
setting.WaffoEnabled = value == "true"
case "WaffoApiKey":
setting.WaffoApiKey = value
case "WaffoPrivateKey":
setting.WaffoPrivateKey = value
case "WaffoPublicCert":
setting.WaffoPublicCert = value
case "WaffoSandboxPublicCert":
setting.WaffoSandboxPublicCert = value
case "WaffoSandboxApiKey":
setting.WaffoSandboxApiKey = value
case "WaffoSandboxPrivateKey":
setting.WaffoSandboxPrivateKey = value
case "WaffoSandbox":
setting.WaffoSandbox = value == "true"
case "WaffoMerchantId":
setting.WaffoMerchantId = value
case "WaffoNotifyUrl":
setting.WaffoNotifyUrl = value
case "WaffoReturnUrl":
setting.WaffoReturnUrl = value
case "WaffoSubscriptionReturnUrl":
setting.WaffoSubscriptionReturnUrl = value
case "WaffoCurrency":
setting.WaffoCurrency = value
case "WaffoUnitPrice":
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoMinTopUp":
setting.WaffoMinTopUp, _ = strconv.Atoi(value)
case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":
@@ -458,6 +504,10 @@ func updateOptionMap(key string, value string) (err error) {
setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
case "PayMethods":
err = operation_setting.UpdatePayMethodsByJsonString(value)
case "WaffoPayMethods":
// WaffoPayMethods is read directly from OptionMap via setting.GetWaffoPayMethods().
// The value is already stored in OptionMap at the top of this function (line: common.OptionMap[key] = value).
// No additional in-memory variable to update.
}
return err
}
+22 -1
View File
@@ -35,6 +35,27 @@ func (token *Token) Clean() {
token.Key = ""
}
func MaskTokenKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 4 {
return strings.Repeat("*", len(key))
}
if len(key) <= 8 {
return key[:2] + "****" + key[len(key)-2:]
}
return key[:4] + "**********" + key[len(key)-4:]
}
func (token *Token) GetFullKey() string {
return token.Key
}
func (token *Token) GetMaskedKey() string {
return MaskTokenKey(token.Key)
}
func (token *Token) GetIpLimits() []string {
// delete empty spaces
//split with \n
@@ -201,7 +222,7 @@ func ValidateUserToken(key string) (token *Token, err error) {
}
keyPrefix := key[:3]
keySuffix := key[len(key)-3:]
return token, errors.New(fmt.Sprintf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota))
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
}
return token, nil
}
+68 -9
View File
@@ -12,15 +12,15 @@ import (
)
type TopUp struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"`
Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
CreateTime int64 `json:"create_time"`
CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
}
func (topUp *TopUp) Insert() error {
@@ -376,3 +376,62 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return nil
}
func RechargeWaffo(tradeNo string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
var quotaToAdd int
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.Status == common.TopUpStatusSuccess {
return nil // 幂等:已成功直接返回
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
dAmount := decimal.NewFromInt(topUp.Amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
if quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}
return nil
})
if err != nil {
common.SysError("waffo topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
if quotaToAdd > 0 {
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
}
return nil
}
+1
View File
@@ -25,6 +25,7 @@ var ModelList = []string{
"claude-opus-4-6-high",
"claude-opus-4-6-medium",
"claude-opus-4-6-low",
"claude-sonnet-4-6",
}
var ChannelName = "claude"
+2 -1
View File
@@ -8,7 +8,8 @@ import (
var baseModelList = []string{
"gpt-5", "gpt-5-codex", "gpt-5-codex-mini",
"gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini",
"gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex",
"gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark",
"gpt-5.4",
}
var ModelList = withCompactModelSuffix(baseModelList)
+25 -20
View File
@@ -2,29 +2,34 @@ package gemini
var ModelList = []string{
// stable version
"gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-flash-8b",
"gemini-2.0-flash",
"gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash",
"gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", "gemini-2.0-flash-lite",
"gemini-2.5-flash-lite",
// latest version
"gemini-1.5-pro-latest", "gemini-1.5-flash-latest",
"gemini-flash-latest", "gemini-flash-lite-latest", "gemini-pro-latest",
"gemini-2.5-flash-native-audio-latest",
// preview version
"gemini-2.0-flash-lite-preview",
"gemini-3-pro-preview",
// gemini exp
"gemini-exp-1206",
// flash exp
"gemini-2.0-flash-exp",
// pro exp
"gemini-2.0-pro-exp",
// thinking exp
"gemini-2.0-flash-thinking-exp",
"gemini-2.5-pro-exp-03-25",
"gemini-2.5-pro-preview-03-25",
// imagen models
"imagen-3.0-generate-002",
"gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts",
"gemini-2.5-flash-image", "gemini-2.5-flash-lite-preview-09-2025",
"gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-3.1-pro-preview",
"gemini-3.1-pro-preview-customtools", "gemini-3.1-flash-lite-preview",
"gemini-3-pro-image-preview", "nano-banana-pro-preview",
"gemini-3.1-flash-image-preview", "gemini-robotics-er-1.5-preview",
"gemini-2.5-computer-use-preview-10-2025", "deep-research-pro-preview-12-2025",
"gemini-2.5-flash-native-audio-preview-09-2025", "gemini-2.5-flash-native-audio-preview-12-2025",
// gemma models
"gemma-3-1b-it", "gemma-3-4b-it", "gemma-3-12b-it",
"gemma-3-27b-it", "gemma-3n-e4b-it", "gemma-3n-e2b-it",
// embedding models
"gemini-embedding-exp-03-07",
"text-embedding-004",
"embedding-001",
"gemini-embedding-001", "gemini-embedding-2-preview",
// imagen models
"imagen-4.0-generate-001", "imagen-4.0-ultra-generate-001",
"imagen-4.0-fast-generate-001",
// veo models
"veo-2.0-generate-001", "veo-3.0-generate-001", "veo-3.0-fast-generate-001",
"veo-3.1-generate-preview", "veo-3.1-fast-generate-preview",
// other models
"aqa",
}
var SafetySettingList = []string{
+3 -1
View File
@@ -15,8 +15,10 @@ var ModelList = []string{
"speech-01-hd",
"speech-01-turbo",
"MiniMax-M2.1",
"MiniMax-M2.1-lightning",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
}
var ChannelName = "minimax"
+5 -3
View File
@@ -1,9 +1,11 @@
package moonshot
var ModelList = []string{
"moonshot-v1-8k",
"moonshot-v1-32k",
"moonshot-v1-128k",
"kimi-k2.5",
"kimi-k2-0905-preview",
"kimi-k2-turbo-preview",
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
}
var ChannelName = "moonshot"
+6 -2
View File
@@ -225,8 +225,12 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *
}
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
header.Set("HTTP-Referer", "https://www.newapi.ai")
header.Set("X-Title", "New API")
if header.Get("HTTP-Referer") == "" {
header.Set("HTTP-Referer", "https://www.newapi.ai")
}
if header.Get("X-OpenRouter-Title") == "" {
header.Set("X-OpenRouter-Title", "New API")
}
}
return nil
}
+33 -4
View File
@@ -3,14 +3,19 @@ package openai
var ModelList = []string{
"gpt-3.5-turbo", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-0125",
"gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k-0613",
"gpt-3.5-turbo-instruct",
"gpt-3.5-turbo-instruct", "gpt-3.5-turbo-instruct-0914",
"gpt-4", "gpt-4-0613", "gpt-4-1106-preview", "gpt-4-0125-preview",
"gpt-4-32k", "gpt-4-32k-0613",
"gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09",
"gpt-4-vision-preview",
"chatgpt-4o-latest",
"gpt-4o", "gpt-4o-2024-05-13", "gpt-4o-2024-08-06", "gpt-4o-2024-11-20",
"gpt-4o-transcribe", "gpt-4o-transcribe-diarize",
"gpt-4o-search-preview", "gpt-4o-search-preview-2025-03-11",
"gpt-4o-mini", "gpt-4o-mini-2024-07-18",
"gpt-4o-mini-transcribe", "gpt-4o-mini-transcribe-2025-03-20", "gpt-4o-mini-transcribe-2025-12-15",
"gpt-4o-mini-tts", "gpt-4o-mini-tts-2025-03-20", "gpt-4o-mini-tts-2025-12-15",
"gpt-4o-mini-search-preview", "gpt-4o-mini-search-preview-2025-03-11",
"gpt-4.5-preview", "gpt-4.5-preview-2025-02-27",
"gpt-4.1", "gpt-4.1-2025-04-14",
"gpt-4.1-mini", "gpt-4.1-mini-2025-04-14",
@@ -31,17 +36,41 @@ var ModelList = []string{
"gpt-5", "gpt-5-2025-08-07", "gpt-5-chat-latest",
"gpt-5-mini", "gpt-5-mini-2025-08-07",
"gpt-5-nano", "gpt-5-nano-2025-08-07",
"gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01",
"gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01", "gpt-4o-realtime-preview-2024-12-17",
"gpt-5-codex",
"gpt-5-pro", "gpt-5-pro-2025-10-06",
"gpt-5-search-api", "gpt-5-search-api-2025-10-14",
"gpt-5.1", "gpt-5.1-2025-11-13", "gpt-5.1-chat-latest",
"gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max",
"gpt-5.2", "gpt-5.2-2025-12-11", "gpt-5.2-chat-latest",
"gpt-5.2-pro", "gpt-5.2-pro-2025-12-11",
"gpt-5.2-codex",
"gpt-5.3-chat-latest",
"gpt-5.3-codex",
"gpt-5.4", "gpt-5.4-2026-03-05",
"gpt-5.4-pro", "gpt-5.4-pro-2026-03-05",
"gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01", "gpt-4o-audio-preview-2024-12-17", "gpt-4o-audio-preview-2025-06-03",
"gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01", "gpt-4o-realtime-preview-2024-12-17", "gpt-4o-realtime-preview-2025-06-03",
"gpt-4o-mini-realtime-preview", "gpt-4o-mini-realtime-preview-2024-12-17",
"gpt-4o-mini-audio-preview", "gpt-4o-mini-audio-preview-2024-12-17",
"gpt-audio", "gpt-audio-2025-08-28",
"gpt-audio-mini", "gpt-audio-mini-2025-10-06", "gpt-audio-mini-2025-12-15",
"gpt-audio-1.5",
"gpt-realtime", "gpt-realtime-2025-08-28",
"gpt-realtime-mini", "gpt-realtime-mini-2025-10-06", "gpt-realtime-mini-2025-12-15",
"gpt-realtime-1.5",
"text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large",
"text-curie-001", "text-babbage-001", "text-ada-001",
"text-moderation-latest", "text-moderation-stable",
"omni-moderation-latest", "omni-moderation-2024-09-26",
"text-davinci-edit-001",
"davinci-002", "babbage-002",
"dall-e-3", "gpt-image-1",
"dall-e-2", "dall-e-3",
"gpt-image-1", "gpt-image-1-mini", "gpt-image-1.5",
"chatgpt-image-latest",
"whisper-1",
"tts-1", "tts-1-1106", "tts-1-hd", "tts-1-hd-1106",
"computer-use-preview", "computer-use-preview-2025-03-11",
"sora-2", "sora-2-pro",
}
var ChannelName = "openai"
+7
View File
@@ -405,5 +405,12 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro
Code: fmt.Sprintf("%d", klingResp.Code),
}
}
// https://app.klingai.com/cn/dev/document-api/apiReference/model/textToVideo
if data := klingResp.Data; data.TaskStatus == "failed" {
openAIVideo.Error = &dto.OpenAIVideoError{
Message: data.TaskStatusMsg,
}
}
return common.Marshal(openAIVideo)
}
+1 -1
View File
@@ -1,7 +1,7 @@
package zhipu_4v
var ModelList = []string{
"glm-4", "glm-4v", "glm-3-turbo", "glm-4-alltools", "glm-4-plus", "glm-4-0520", "glm-4-air", "glm-4-airx", "glm-4-long", "glm-4-flash", "glm-4v-plus", "glm-4.6",
"glm-4", "glm-4v", "glm-3-turbo", "glm-4-alltools", "glm-4-plus", "glm-4-0520", "glm-4-air", "glm-4-airx", "glm-4-long", "glm-4-flash", "glm-4v-plus", "glm-4.6", "glm-4.6v", "glm-4.7", "glm-4.7-flash", "glm-5",
}
var ChannelName = "zhipu_4v"
+266 -11
View File
@@ -21,10 +21,23 @@ var negativeIndexRegexp = regexp.MustCompile(`\.(-\d+)`)
const (
paramOverrideContextRequestHeaders = "request_headers"
paramOverrideContextHeaderOverride = "header_override"
paramOverrideContextAuditRecorder = "__param_override_audit_recorder"
)
var errSourceHeaderNotFound = errors.New("source header does not exist")
var paramOverrideKeyAuditPaths = map[string]struct{}{
"model": {},
"original_model": {},
"upstream_model": {},
"service_tier": {},
"inference_geo": {},
}
type paramOverrideAuditRecorder struct {
lines []string
}
type ConditionOperation struct {
Path string `json:"path"` // JSON路径
Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte
@@ -118,6 +131,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c
if len(paramOverride) == 0 {
return jsonData, nil
}
auditRecorder := getParamOverrideAuditRecorder(conditionContext)
// 尝试断言为操作格式
if operations, ok := tryParseOperations(paramOverride); ok {
@@ -125,7 +139,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c
workingJSON := jsonData
var err error
if len(legacyOverride) > 0 {
workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride)
workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride, auditRecorder)
if err != nil {
return nil, err
}
@@ -137,7 +151,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c
}
// 直接使用旧方法
return applyOperationsLegacy(jsonData, paramOverride)
return applyOperationsLegacy(jsonData, paramOverride, auditRecorder)
}
func buildLegacyParamOverride(paramOverride map[string]interface{}) map[string]interface{} {
@@ -161,14 +175,200 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte,
}
overrideCtx := BuildParamOverrideContext(info)
var recorder *paramOverrideAuditRecorder
if shouldEnableParamOverrideAudit(paramOverride) {
recorder = &paramOverrideAuditRecorder{}
overrideCtx[paramOverrideContextAuditRecorder] = recorder
}
result, err := ApplyParamOverride(jsonData, paramOverride, overrideCtx)
if err != nil {
return nil, err
}
syncRuntimeHeaderOverrideFromContext(info, overrideCtx)
if info != nil {
if recorder != nil {
info.ParamOverrideAudit = recorder.lines
} else {
info.ParamOverrideAudit = nil
}
}
return result, nil
}
func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
if common.DebugEnabled {
return true
}
if len(paramOverride) == 0 {
return false
}
if operations, ok := tryParseOperations(paramOverride); ok {
for _, operation := range operations {
if shouldAuditParamPath(strings.TrimSpace(operation.Path)) ||
shouldAuditParamPath(strings.TrimSpace(operation.To)) {
return true
}
}
for key := range buildLegacyParamOverride(paramOverride) {
if shouldAuditParamPath(strings.TrimSpace(key)) {
return true
}
}
return false
}
for key := range paramOverride {
if shouldAuditParamPath(strings.TrimSpace(key)) {
return true
}
}
return false
}
func getParamOverrideAuditRecorder(context map[string]interface{}) *paramOverrideAuditRecorder {
if context == nil {
return nil
}
recorder, _ := context[paramOverrideContextAuditRecorder].(*paramOverrideAuditRecorder)
return recorder
}
func (r *paramOverrideAuditRecorder) recordOperation(mode, path, from, to string, value interface{}) {
if r == nil {
return
}
line := buildParamOverrideAuditLine(mode, path, from, to, value)
if line == "" {
return
}
if lo.Contains(r.lines, line) {
return
}
r.lines = append(r.lines, line)
}
func shouldAuditParamPath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
if common.DebugEnabled {
return true
}
_, ok := paramOverrideKeyAuditPaths[path]
return ok
}
func shouldAuditOperation(mode, path, from, to string) bool {
if common.DebugEnabled {
return true
}
for _, candidate := range []string{path, to} {
if shouldAuditParamPath(candidate) {
return true
}
}
return false
}
func formatParamOverrideAuditValue(value interface{}) string {
switch typed := value.(type) {
case nil:
return "<empty>"
case string:
return typed
default:
return common.GetJsonString(typed)
}
}
func buildParamOverrideAuditLine(mode, path, from, to string, value interface{}) string {
mode = strings.TrimSpace(mode)
path = strings.TrimSpace(path)
from = strings.TrimSpace(from)
to = strings.TrimSpace(to)
if !shouldAuditOperation(mode, path, from, to) {
return ""
}
switch mode {
case "set":
if path == "" {
return ""
}
return fmt.Sprintf("set %s = %s", path, formatParamOverrideAuditValue(value))
case "delete":
if path == "" {
return ""
}
return fmt.Sprintf("delete %s", path)
case "copy":
if from == "" || to == "" {
return ""
}
return fmt.Sprintf("copy %s -> %s", from, to)
case "move":
if from == "" || to == "" {
return ""
}
return fmt.Sprintf("move %s -> %s", from, to)
case "prepend":
if path == "" {
return ""
}
return fmt.Sprintf("prepend %s with %s", path, formatParamOverrideAuditValue(value))
case "append":
if path == "" {
return ""
}
return fmt.Sprintf("append %s with %s", path, formatParamOverrideAuditValue(value))
case "trim_prefix", "trim_suffix", "ensure_prefix", "ensure_suffix":
if path == "" {
return ""
}
return fmt.Sprintf("%s %s with %s", mode, path, formatParamOverrideAuditValue(value))
case "trim_space", "to_lower", "to_upper":
if path == "" {
return ""
}
return fmt.Sprintf("%s %s", mode, path)
case "replace", "regex_replace":
if path == "" {
return ""
}
return fmt.Sprintf("%s %s from %s to %s", mode, path, from, to)
case "set_header":
if path == "" {
return ""
}
return fmt.Sprintf("set_header %s = %s", path, formatParamOverrideAuditValue(value))
case "delete_header":
if path == "" {
return ""
}
return fmt.Sprintf("delete_header %s", path)
case "copy_header", "move_header":
if from == "" || to == "" {
return ""
}
return fmt.Sprintf("%s %s -> %s", mode, from, to)
case "pass_headers":
return fmt.Sprintf("pass_headers %s", formatParamOverrideAuditValue(value))
case "sync_fields":
if from == "" || to == "" {
return ""
}
return fmt.Sprintf("sync_fields %s -> %s", from, to)
case "return_error":
return fmt.Sprintf("return_error %s", formatParamOverrideAuditValue(value))
default:
if path == "" {
return mode
}
return fmt.Sprintf("%s %s", mode, path)
}
}
func getParamOverrideMap(info *RelayInfo) map[string]interface{} {
if info == nil || info.ChannelMeta == nil {
return nil
@@ -455,7 +655,7 @@ func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool,
}
// applyOperationsLegacy 原参数覆盖方法
func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}) ([]byte, error) {
func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}, auditRecorder *paramOverrideAuditRecorder) ([]byte, error) {
reqMap := make(map[string]interface{})
err := common.Unmarshal(jsonData, &reqMap)
if err != nil {
@@ -464,6 +664,7 @@ func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}
for key, value := range paramOverride {
reqMap[key] = value
auditRecorder.recordOperation("set", key, "", "", value)
}
return common.Marshal(reqMap)
@@ -471,6 +672,7 @@ func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}
func applyOperations(jsonStr string, operations []ParamOperation, conditionContext map[string]interface{}) (string, error) {
context := ensureContextMap(conditionContext)
auditRecorder := getParamOverrideAuditRecorder(context)
contextJSON, err := marshalContextJSON(context)
if err != nil {
return "", fmt.Errorf("failed to marshal condition context: %v", err)
@@ -506,6 +708,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("delete", path, "", "", nil)
}
case "set":
for _, path := range opPaths {
@@ -516,11 +719,15 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("set", path, "", "", op.Value)
}
case "move":
opFrom := processNegativeIndex(result, op.From)
opTo := processNegativeIndex(result, op.To)
result, err = moveValue(result, opFrom, opTo)
if err == nil {
auditRecorder.recordOperation("move", "", opFrom, opTo, nil)
}
case "copy":
if op.From == "" || op.To == "" {
return "", fmt.Errorf("copy from/to is required")
@@ -528,12 +735,16 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
opFrom := processNegativeIndex(result, op.From)
opTo := processNegativeIndex(result, op.To)
result, err = copyValue(result, opFrom, opTo)
if err == nil {
auditRecorder.recordOperation("copy", "", opFrom, opTo, nil)
}
case "prepend":
for _, path := range opPaths {
result, err = modifyValue(result, path, op.Value, op.KeepOrigin, true)
if err != nil {
break
}
auditRecorder.recordOperation("prepend", path, "", "", op.Value)
}
case "append":
for _, path := range opPaths {
@@ -541,6 +752,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("append", path, "", "", op.Value)
}
case "trim_prefix":
for _, path := range opPaths {
@@ -548,6 +760,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("trim_prefix", path, "", "", op.Value)
}
case "trim_suffix":
for _, path := range opPaths {
@@ -555,6 +768,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("trim_suffix", path, "", "", op.Value)
}
case "ensure_prefix":
for _, path := range opPaths {
@@ -562,6 +776,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("ensure_prefix", path, "", "", op.Value)
}
case "ensure_suffix":
for _, path := range opPaths {
@@ -569,6 +784,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("ensure_suffix", path, "", "", op.Value)
}
case "trim_space":
for _, path := range opPaths {
@@ -576,6 +792,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("trim_space", path, "", "", nil)
}
case "to_lower":
for _, path := range opPaths {
@@ -583,6 +800,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("to_lower", path, "", "", nil)
}
case "to_upper":
for _, path := range opPaths {
@@ -590,6 +808,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("to_upper", path, "", "", nil)
}
case "replace":
for _, path := range opPaths {
@@ -597,6 +816,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("replace", path, op.From, op.To, nil)
}
case "regex_replace":
for _, path := range opPaths {
@@ -604,8 +824,10 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
if err != nil {
break
}
auditRecorder.recordOperation("regex_replace", path, op.From, op.To, nil)
}
case "return_error":
auditRecorder.recordOperation("return_error", op.Path, "", "", op.Value)
returnErr, parseErr := parseParamOverrideReturnError(op.Value)
if parseErr != nil {
return "", parseErr
@@ -621,11 +843,13 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
case "set_header":
err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin)
if err == nil {
auditRecorder.recordOperation("set_header", op.Path, "", "", op.Value)
contextJSON, err = marshalContextJSON(context)
}
case "delete_header":
err = deleteHeaderOverrideInContext(context, op.Path)
if err == nil {
auditRecorder.recordOperation("delete_header", op.Path, "", "", nil)
contextJSON, err = marshalContextJSON(context)
}
case "copy_header":
@@ -642,6 +866,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
err = nil
}
if err == nil {
auditRecorder.recordOperation("copy_header", "", sourceHeader, targetHeader, nil)
contextJSON, err = marshalContextJSON(context)
}
case "move_header":
@@ -658,6 +883,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
err = nil
}
if err == nil {
auditRecorder.recordOperation("move_header", "", sourceHeader, targetHeader, nil)
contextJSON, err = marshalContextJSON(context)
}
case "pass_headers":
@@ -675,11 +901,13 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
}
}
if err == nil {
auditRecorder.recordOperation("pass_headers", "", "", "", headerNames)
contextJSON, err = marshalContextJSON(context)
}
case "sync_fields":
result, err = syncFieldsBetweenTargets(result, context, op.From, op.To)
if err == nil {
auditRecorder.recordOperation("sync_fields", "", op.From, op.To, nil)
contextJSON, err = marshalContextJSON(context)
}
default:
@@ -847,24 +1075,30 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN
return "", false, fmt.Errorf("header value mapping cannot be empty")
}
sourceValue, exists := getHeaderValueFromContext(context, headerName)
if !exists {
return "", false, nil
appendTokens, err := parseHeaderAppendTokens(mapping)
if err != nil {
return "", false, err
}
sourceTokens := splitHeaderListValue(sourceValue)
if len(sourceTokens) == 0 {
return "", false, nil
keepOnlyDeclared := parseHeaderKeepOnlyDeclared(mapping)
sourceValue, exists := getHeaderValueFromContext(context, headerName)
sourceTokens := make([]string, 0)
if exists {
sourceTokens = splitHeaderListValue(sourceValue)
}
wildcardValue, hasWildcard := mapping["*"]
resultTokens := make([]string, 0, len(sourceTokens))
resultTokens := make([]string, 0, len(sourceTokens)+len(appendTokens))
for _, token := range sourceTokens {
replacementRaw, hasReplacement := mapping[token]
if !hasReplacement && hasWildcard {
if !hasReplacement && hasWildcard && !keepOnlyDeclared {
replacementRaw = wildcardValue
hasReplacement = true
}
if !hasReplacement {
if keepOnlyDeclared {
continue
}
resultTokens = append(resultTokens, token)
continue
}
@@ -875,6 +1109,7 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN
resultTokens = append(resultTokens, replacementTokens...)
}
resultTokens = append(resultTokens, appendTokens...)
resultTokens = lo.Uniq(resultTokens)
if len(resultTokens) == 0 {
return "", false, nil
@@ -882,6 +1117,26 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN
return strings.Join(resultTokens, ","), true, nil
}
func parseHeaderAppendTokens(mapping map[string]interface{}) ([]string, error) {
appendRaw, ok := mapping["$append"]
if !ok {
return nil, nil
}
return parseHeaderReplacementTokens(appendRaw)
}
func parseHeaderKeepOnlyDeclared(mapping map[string]interface{}) bool {
keepOnlyDeclaredRaw, ok := mapping["$keep_only_declared"]
if !ok {
return false
}
keepOnlyDeclared, ok := keepOnlyDeclaredRaw.(bool)
if !ok {
return false
}
return keepOnlyDeclared
}
func parseHeaderReplacementTokens(value interface{}) ([]string, error) {
switch raw := value.(type) {
case nil:
+235
View File
@@ -6,6 +6,7 @@ import (
"reflect"
"testing"
common2 "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/types"
"github.com/QuantumNous/new-api/dto"
@@ -1653,6 +1654,141 @@ func TestApplyParamOverrideSetHeaderMapDeleteWholeHeaderWhenAllTokensCleared(t *
}
}
func TestApplyParamOverrideSetHeaderMapAppendsTokens(t *testing.T) {
input := []byte(`{"temperature":0.7}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"mode": "set_header",
"path": "anthropic-beta",
"value": map[string]interface{}{
"$append": []interface{}{"context-1m-2025-08-07", "computer-use-2025-01-24"},
},
},
},
}
ctx := map[string]interface{}{
"header_override": map[string]interface{}{
"anthropic-beta": "computer-use-2025-01-24",
},
}
out, err := ApplyParamOverride(input, override, ctx)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
headers, ok := ctx["header_override"].(map[string]interface{})
if !ok {
t.Fatalf("expected header_override context map")
}
if headers["anthropic-beta"] != "computer-use-2025-01-24,context-1m-2025-08-07" {
t.Fatalf("expected anthropic-beta to append new token without duplicates, got: %v", headers["anthropic-beta"])
}
}
func TestApplyParamOverrideSetHeaderMapAppendsTokensWhenHeaderMissing(t *testing.T) {
input := []byte(`{"temperature":0.7}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"mode": "set_header",
"path": "anthropic-beta",
"value": map[string]interface{}{
"$append": []interface{}{"context-1m-2025-08-07", "computer-use-2025-01-24"},
},
},
},
}
ctx := map[string]interface{}{}
out, err := ApplyParamOverride(input, override, ctx)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
headers, ok := ctx["header_override"].(map[string]interface{})
if !ok {
t.Fatalf("expected header_override context map")
}
if headers["anthropic-beta"] != "context-1m-2025-08-07,computer-use-2025-01-24" {
t.Fatalf("expected anthropic-beta to be created from appended tokens, got: %v", headers["anthropic-beta"])
}
}
func TestApplyParamOverrideSetHeaderMapKeepOnlyDeclaredDropsUndeclaredTokens(t *testing.T) {
input := []byte(`{"temperature":0.7}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"mode": "set_header",
"path": "anthropic-beta",
"value": map[string]interface{}{
"computer-use-2025-01-24": "computer-use-2025-01-24",
"$append": []interface{}{"context-1m-2025-08-07"},
"$keep_only_declared": true,
},
},
},
}
ctx := map[string]interface{}{
"header_override": map[string]interface{}{
"anthropic-beta": "advanced-tool-use-2025-11-20,computer-use-2025-01-24",
},
}
out, err := ApplyParamOverride(input, override, ctx)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
headers, ok := ctx["header_override"].(map[string]interface{})
if !ok {
t.Fatalf("expected header_override context map")
}
if headers["anthropic-beta"] != "computer-use-2025-01-24,context-1m-2025-08-07" {
t.Fatalf("expected anthropic-beta to keep only declared tokens, got: %v", headers["anthropic-beta"])
}
}
func TestApplyParamOverrideSetHeaderMapKeepOnlyDeclaredDeletesHeaderWhenNothingDeclaredMatches(t *testing.T) {
input := []byte(`{"temperature":0.7}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"mode": "set_header",
"path": "anthropic-beta",
"value": map[string]interface{}{
"computer-use-2025-01-24": "computer-use-2025-01-24",
"$keep_only_declared": true,
},
},
},
}
ctx := map[string]interface{}{
"header_override": map[string]interface{}{
"anthropic-beta": "advanced-tool-use-2025-11-20",
},
}
out, err := ApplyParamOverride(input, override, ctx)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
assertJSONEqual(t, `{"temperature":0.7}`, string(out))
headers, ok := ctx["header_override"].(map[string]interface{})
if !ok {
t.Fatalf("expected header_override context map")
}
if _, exists := headers["anthropic-beta"]; exists {
t.Fatalf("expected anthropic-beta to be deleted when no declared tokens remain, got: %v", headers["anthropic-beta"])
}
}
func TestApplyParamOverrideConditionsObjectShorthand(t *testing.T) {
input := []byte(`{"temperature":0.7}`)
override := map[string]interface{}{
@@ -1931,6 +2067,105 @@ func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
assertJSONEqual(t, `{"inference_geo":"eu","store":true}`, string(out))
}
func TestApplyParamOverrideWithRelayInfoRecordsOperationAuditInDebugMode(t *testing.T) {
originalDebugEnabled := common2.DebugEnabled
common2.DebugEnabled = true
t.Cleanup(func() {
common2.DebugEnabled = originalDebugEnabled
})
info := &RelayInfo{
ChannelMeta: &ChannelMeta{
ParamOverride: map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"mode": "copy",
"from": "metadata.target_model",
"to": "model",
},
map[string]interface{}{
"mode": "set",
"path": "service_tier",
"value": "flex",
},
map[string]interface{}{
"mode": "set",
"path": "temperature",
"value": 0.1,
},
},
},
},
}
out, err := ApplyParamOverrideWithRelayInfo([]byte(`{
"model":"gpt-4.1",
"temperature":0.7,
"metadata":{"target_model":"gpt-4.1-mini"}
}`), info)
if err != nil {
t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err)
}
assertJSONEqual(t, `{
"model":"gpt-4.1-mini",
"temperature":0.1,
"service_tier":"flex",
"metadata":{"target_model":"gpt-4.1-mini"}
}`, string(out))
expected := []string{
"copy metadata.target_model -> model",
"set service_tier = flex",
"set temperature = 0.1",
}
if !reflect.DeepEqual(info.ParamOverrideAudit, expected) {
t.Fatalf("unexpected param override audit, got %#v", info.ParamOverrideAudit)
}
}
func TestApplyParamOverrideWithRelayInfoRecordsOnlyKeyOperationsWhenDebugDisabled(t *testing.T) {
originalDebugEnabled := common2.DebugEnabled
common2.DebugEnabled = false
t.Cleanup(func() {
common2.DebugEnabled = originalDebugEnabled
})
info := &RelayInfo{
ChannelMeta: &ChannelMeta{
ParamOverride: map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"mode": "copy",
"from": "metadata.target_model",
"to": "model",
},
map[string]interface{}{
"mode": "set",
"path": "temperature",
"value": 0.1,
},
},
},
},
}
_, err := ApplyParamOverrideWithRelayInfo([]byte(`{
"model":"gpt-4.1",
"temperature":0.7,
"metadata":{"target_model":"gpt-4.1-mini"}
}`), info)
if err != nil {
t.Fatalf("ApplyParamOverrideWithRelayInfo returned error: %v", err)
}
expected := []string{
"copy metadata.target_model -> model",
}
if !reflect.DeepEqual(info.ParamOverrideAudit, expected) {
t.Fatalf("unexpected param override audit, got %#v", info.ParamOverrideAudit)
}
}
func assertJSONEqual(t *testing.T, want, got string) {
t.Helper()
+1
View File
@@ -149,6 +149,7 @@ type RelayInfo struct {
LastError *types.NewAPIError
RuntimeHeadersOverride map[string]interface{}
UseRuntimeHeadersOverride bool
ParamOverrideAudit []string
PriceData types.PriceData
+3
View File
@@ -48,6 +48,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
@@ -89,6 +90,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting)
@@ -248,6 +250,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
tokenRoute.GET("/:id", controller.GetToken)
tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey)
tokenRoute.POST("/", controller.AddToken)
tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken)
+1 -1
View File
@@ -214,7 +214,7 @@ func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) {
relayMjRouter.POST("/submit/blend", controller.RelayMidjourney)
relayMjRouter.POST("/submit/edits", controller.RelayMidjourney)
relayMjRouter.POST("/submit/video", controller.RelayMidjourney)
relayMjRouter.POST("/notify", controller.RelayMidjourney)
//relayMjRouter.POST("/notify", controller.RelayMidjourney)
relayMjRouter.GET("/task/:id/fetch", controller.RelayMidjourney)
relayMjRouter.GET("/task/:id/image-seed", controller.RelayMidjourney)
relayMjRouter.POST("/task/list-by-condition", controller.RelayMidjourney)
+8
View File
@@ -74,9 +74,17 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
appendRequestPath(ctx, relayInfo, other)
appendRequestConversionChain(relayInfo, other)
appendBillingInfo(relayInfo, other)
appendParamOverrideInfo(relayInfo, other)
return other
}
func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 {
return
}
other["po"] = relayInfo.ParamOverrideAudit
}
func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if relayInfo == nil || other == nil {
return
+26 -12
View File
@@ -2,6 +2,7 @@ package model_setting
import (
"net/http"
"strings"
"github.com/QuantumNous/new-api/setting/config"
)
@@ -50,23 +51,36 @@ func GetClaudeSettings() *ClaudeSettings {
func (c *ClaudeSettings) WriteHeaders(originModel string, httpHeader *http.Header) {
if headers, ok := c.HeadersSettings[originModel]; ok {
for headerKey, headerValues := range headers {
// get existing values for this header key
existingValues := httpHeader.Values(headerKey)
existingValuesMap := make(map[string]bool)
for _, v := range existingValues {
existingValuesMap[v] = true
}
// add only values that don't already exist
for _, headerValue := range headerValues {
if !existingValuesMap[headerValue] {
httpHeader.Add(headerKey, headerValue)
}
mergedValues := normalizeHeaderListValues(
append(append([]string(nil), httpHeader.Values(headerKey)...), headerValues...),
)
if len(mergedValues) == 0 {
continue
}
httpHeader.Set(headerKey, strings.Join(mergedValues, ","))
}
}
}
func normalizeHeaderListValues(values []string) []string {
normalizedValues := make([]string, 0, len(values))
seenValues := make(map[string]struct{}, len(values))
for _, value := range values {
for _, item := range strings.Split(value, ",") {
normalizedItem := strings.TrimSpace(item)
if normalizedItem == "" {
continue
}
if _, exists := seenValues[normalizedItem]; exists {
continue
}
seenValues[normalizedItem] = struct{}{}
normalizedValues = append(normalizedValues, normalizedItem)
}
}
return normalizedValues
}
func (c *ClaudeSettings) GetDefaultMaxTokens(model string) int {
if maxTokens, ok := c.DefaultMaxTokens[model]; ok {
return maxTokens
+67
View File
@@ -0,0 +1,67 @@
package setting
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
)
var (
WaffoEnabled bool
WaffoApiKey string
WaffoPrivateKey string
WaffoPublicCert string
WaffoSandboxPublicCert string
WaffoSandboxApiKey string
WaffoSandboxPrivateKey string
WaffoSandbox bool
WaffoMerchantId string
WaffoNotifyUrl string
WaffoReturnUrl string
WaffoSubscriptionReturnUrl string
WaffoCurrency string
WaffoUnitPrice float64 = 1.0
WaffoMinTopUp int = 1
)
// GetWaffoPayMethods 从 options 读取 Waffo 支付方式配置
func GetWaffoPayMethods() []constant.WaffoPayMethod {
common.OptionMapRWMutex.RLock()
jsonStr := common.OptionMap["WaffoPayMethods"]
common.OptionMapRWMutex.RUnlock()
if jsonStr == "" {
return copyDefaultWaffoPayMethods()
}
var methods []constant.WaffoPayMethod
if err := common.UnmarshalJsonStr(jsonStr, &methods); err != nil {
return copyDefaultWaffoPayMethods()
}
return methods
}
// SetWaffoPayMethods 序列化 Waffo 支付方式配置并更新 OptionMap
func SetWaffoPayMethods(methods []constant.WaffoPayMethod) error {
jsonBytes, err := common.Marshal(methods)
if err != nil {
return err
}
common.OptionMapRWMutex.Lock()
common.OptionMap["WaffoPayMethods"] = string(jsonBytes)
common.OptionMapRWMutex.Unlock()
return nil
}
func copyDefaultWaffoPayMethods() []constant.WaffoPayMethod {
cp := make([]constant.WaffoPayMethod, len(constant.DefaultWaffoPayMethods))
copy(cp, constant.DefaultWaffoPayMethods)
return cp
}
// WaffoPayMethods2JsonString 将默认 WaffoPayMethods 序列化为 JSON 字符串(供 InitOptionMap 使用)
func WaffoPayMethods2JsonString() string {
jsonBytes, err := common.Marshal(constant.DefaultWaffoPayMethods)
if err != nil {
return "[]"
}
return string(jsonBytes)
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+27 -8
View File
@@ -41,7 +41,7 @@ import { normalizeLanguage } from '../../i18n/language';
const { Sider, Content, Header } = Layout;
const PageLayout = () => {
const [, userDispatch] = useContext(UserContext);
const [userState, userDispatch] = useContext(UserContext);
const [, statusDispatch] = useContext(StatusContext);
const isMobile = useIsMobile();
const [collapsed, , setCollapsed] = useSidebarCollapsed();
@@ -114,15 +114,34 @@ const PageLayout = () => {
linkElement.href = logo;
}
}
const savedLang = localStorage.getItem('i18nextLng');
if (savedLang) {
const normalizedLang = normalizeLanguage(savedLang);
if (normalizedLang !== savedLang) {
localStorage.setItem('i18nextLng', normalizedLang);
}, []);
useEffect(() => {
let preferredLang;
if (userState?.user?.setting) {
try {
const settings = JSON.parse(userState.user.setting);
preferredLang = normalizeLanguage(settings.language);
} catch (e) {
// Ignore parse errors
}
i18n.changeLanguage(normalizedLang);
}
}, [i18n]);
if (!preferredLang) {
const savedLang = localStorage.getItem('i18nextLng');
if (savedLang) {
preferredLang = normalizeLanguage(savedLang);
}
}
if (preferredLang) {
localStorage.setItem('i18nextLng', preferredLang);
if (preferredLang !== i18n.language) {
i18n.changeLanguage(preferredLang);
}
}
}, [i18n, userState?.user?.setting]);
return (
<Layout
@@ -23,6 +23,7 @@ import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralP
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
@@ -66,7 +67,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
console.error('解析TopupGroupRatio出错:', error);
newInputs[item.key] = item.value;
}
break;
@@ -78,7 +78,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
console.error('解析AmountOptions出错:', error);
newInputs['AmountOptions'] = item.value;
}
break;
@@ -90,7 +89,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
console.error('解析AmountDiscount出错:', error);
newInputs['AmountDiscount'] = item.value;
}
break;
@@ -146,6 +144,9 @@ const PaymentSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayWaffo options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
@@ -73,6 +73,7 @@ const PreferencesSettings = ({ t }) => {
// Update language immediately for responsive UX
setCurrentLanguage(lang);
i18n.changeLanguage(lang);
localStorage.setItem('i18nextLng', lang);
// Save to backend
const res = await API.put("/api/user/self", {
@@ -81,33 +82,38 @@ const PreferencesSettings = ({ t }) => {
if (res.data.success) {
showSuccess(t("语言偏好已保存"));
// Update user context with new setting
// Keep backend preference, context state, and local cache aligned.
let settings = {};
if (userState?.user?.setting) {
try {
const settings = JSON.parse(userState.user.setting);
settings.language = lang;
userDispatch({
type: "login",
payload: {
...userState.user,
setting: JSON.stringify(settings),
},
});
settings = JSON.parse(userState.user.setting) || {};
} catch (e) {
// Ignore
settings = {};
}
}
settings.language = lang;
const nextUser = {
...userState.user,
setting: JSON.stringify(settings),
};
userDispatch({
type: "login",
payload: nextUser,
});
localStorage.setItem("user", JSON.stringify(nextUser));
} else {
showError(res.data.message || t("保存失败"));
// Revert on error
setCurrentLanguage(previousLang);
i18n.changeLanguage(previousLang);
localStorage.setItem("i18nextLng", previousLang);
}
} catch (error) {
showError(t("保存失败,请重试"));
// Revert on error
setCurrentLanguage(previousLang);
i18n.changeLanguage(previousLang);
localStorage.setItem("i18nextLng", previousLang);
} finally {
setLoading(false);
}
@@ -537,7 +537,12 @@ export const getChannelsColumns = ({
</Tag>
</Tooltip>
<Tooltip
content={t('剩余额度$') + record.balance + t(',点击更新')}
content={
t('剩余额度') +
': ' +
renderQuotaWithAmount(record.balance) +
t(',点击更新')
}
>
<Tag
color='white'
@@ -43,6 +43,68 @@ const pickStrokeColor = (percent) => {
return '#3b82f6';
};
const normalizePlanType = (value) => {
if (value == null) return '';
return String(value).trim().toLowerCase();
};
const getWindowDurationSeconds = (windowData) => {
const value = Number(windowData?.limit_window_seconds);
if (!Number.isFinite(value) || value <= 0) return null;
return value;
};
const classifyWindowByDuration = (windowData) => {
const seconds = getWindowDurationSeconds(windowData);
if (seconds == null) return null;
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour';
};
const resolveRateLimitWindows = (data) => {
const rateLimit = data?.rate_limit ?? {};
const primary = rateLimit?.primary_window ?? null;
const secondary = rateLimit?.secondary_window ?? null;
const windows = [primary, secondary].filter(Boolean);
const planType = normalizePlanType(data?.plan_type ?? rateLimit?.plan_type);
let fiveHourWindow = null;
let weeklyWindow = null;
for (const windowData of windows) {
const bucket = classifyWindowByDuration(windowData);
if (bucket === 'fiveHour' && !fiveHourWindow) {
fiveHourWindow = windowData;
continue;
}
if (bucket === 'weekly' && !weeklyWindow) {
weeklyWindow = windowData;
}
}
if (planType === 'free') {
if (!weeklyWindow) {
weeklyWindow = primary ?? secondary ?? null;
}
return { fiveHourWindow: null, weeklyWindow };
}
if (!fiveHourWindow && !weeklyWindow) {
return {
fiveHourWindow: primary ?? null,
weeklyWindow: secondary ?? null,
};
}
if (!fiveHourWindow) {
fiveHourWindow = windows.find((windowData) => windowData !== weeklyWindow) ?? null;
}
if (!weeklyWindow) {
weeklyWindow = windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
}
return { fiveHourWindow, weeklyWindow };
};
const formatDurationSeconds = (seconds, t) => {
const tt = typeof t === 'function' ? t : (v) => v;
const s = Number(seconds);
@@ -68,6 +130,10 @@ const formatUnixSeconds = (unixSeconds) => {
const RateLimitWindowCard = ({ t, title, windowData }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const hasWindowData =
!!windowData &&
typeof windowData === 'object' &&
Object.keys(windowData).length > 0;
const percent = clampPercent(windowData?.used_percent ?? 0);
const resetAt = windowData?.reset_at;
const resetAfterSeconds = windowData?.reset_after_seconds;
@@ -83,26 +149,30 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
</Text>
</div>
<div className='mt-2'>
<Progress
percent={percent}
stroke={pickStrokeColor(percent)}
showInfo={true}
/>
</div>
{hasWindowData ? (
<div className='mt-2'>
<Progress
percent={percent}
stroke={pickStrokeColor(percent)}
showInfo={true}
/>
</div>
) : (
<div className='mt-3 text-sm text-semi-color-text-2'>-</div>
)}
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
<div>
{tt('已使用:')}
{percent}%
{hasWindowData ? `${percent}%` : '-'}
</div>
<div>
{tt('距离重置:')}
{formatDurationSeconds(resetAfterSeconds, tt)}
{hasWindowData ? formatDurationSeconds(resetAfterSeconds, tt) : '-'}
</div>
<div>
{tt('窗口:')}
{formatDurationSeconds(limitWindowSeconds, tt)}
{hasWindowData ? formatDurationSeconds(limitWindowSeconds, tt) : '-'}
</div>
</div>
</div>
@@ -113,9 +183,7 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const data = payload?.data ?? null;
const rateLimit = data?.rate_limit ?? {};
const primary = rateLimit?.primary_window ?? null;
const secondary = rateLimit?.secondary_window ?? null;
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(data);
const allowed = !!rateLimit?.allowed;
const limitReached = !!rateLimit?.limit_reached;
@@ -163,12 +231,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
<RateLimitWindowCard
t={tt}
title={tt('5小时窗口')}
windowData={primary}
windowData={fiveHourWindow}
/>
<RateLimitWindowCard
t={tt}
title={tt('每周窗口')}
windowData={secondary}
windowData={weeklyWindow}
/>
</div>
@@ -34,7 +34,7 @@ import {
TextArea,
Typography,
} from '@douyinfe/semi-ui';
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
import { IconDelete, IconMenu, IconPlus } from '@douyinfe/semi-icons';
import { copy, showError, showSuccess, verifyJSON } from '../../../../helpers';
import {
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
@@ -163,7 +163,7 @@ const MODE_DESCRIPTIONS = {
prune_objects: '按条件清理对象中的子项',
pass_headers: '把指定请求头透传到上游请求',
sync_fields: '在一个字段有值、另一个缺失时自动补齐',
set_header: '设置运行期请求头(支持整值覆盖,或用 JSON 映射按逗号 token 替换/删除)',
set_header: '设置运行期请求头:可直接覆盖整条值,也可对逗号分隔的 token 做删除、替换、追加或白名单保留',
delete_header: '删除运行期请求头',
copy_header: '复制请求头',
move_header: '移动请求头',
@@ -230,17 +230,29 @@ const getModeValueLabel = (mode) => {
return '值(支持 JSON 或普通文本)';
};
const HEADER_VALUE_JSONC_EXAMPLE = `{
// Bedrock beta
"files-api-2025-04-14": null,
//
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
//
"$append": ["context-1m-2025-08-07"]
}`;
const getModeValuePlaceholder = (mode) => {
if (mode === 'set_header') {
return [
'String example:',
'纯字符串(整条覆盖):',
'Bearer sk-xxx',
'',
'JSON map example:',
'{"advanced-tool-use-2025-11-20": null, "computer-use-2025-01-24": "computer-use-2025-01-24"}',
'',
'JSON map wildcard:',
'{"*": null, "computer-use-2025-11-24": "computer-use-2025-11-24"}',
'或使用 JSON 规则:',
'{',
' "files-api-2025-04-14": null,',
' "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",',
' "$append": ["context-1m-2025-08-07"]',
'}',
].join('\n');
}
if (mode === 'pass_headers') return 'Authorization, X-Request-Id';
@@ -258,11 +270,6 @@ const getModeValuePlaceholder = (mode) => {
return '0.7';
};
const getModeValueHelp = (mode) => {
if (mode !== 'set_header') return '';
return '字符串:整条请求头直接覆盖。JSON 映射:按逗号分隔 token 逐项处理,null 表示删除,string/array 表示替换,* 表示兜底规则。';
};
const SYNC_TARGET_TYPE_OPTIONS = [
{ label: '请求体字段', value: 'json' },
{ label: '请求头字段', value: 'header' },
@@ -369,6 +376,7 @@ const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = {
'tool-search-tool-2025-10-19': 'tool-search-tool-2025-10-19',
'web-fetch-2025-09-10': null,
'web-search-2025-03-05': null,
'oauth-2025-04-20': null
},
},
{
@@ -800,6 +808,38 @@ const normalizeOperation = (operation = {}) => ({
const createDefaultOperation = () => normalizeOperation({ mode: 'set' });
const reorderOperations = (
sourceOperations = [],
sourceId,
targetId,
position = 'before',
) => {
if (!sourceId || !targetId || sourceId === targetId) {
return sourceOperations;
}
const sourceIndex = sourceOperations.findIndex((item) => item.id === sourceId);
if (sourceIndex < 0) {
return sourceOperations;
}
const nextOperations = [...sourceOperations];
const [moved] = nextOperations.splice(sourceIndex, 1);
let insertIndex = nextOperations.findIndex((item) => item.id === targetId);
if (insertIndex < 0) {
return sourceOperations;
}
if (position === 'after') {
insertIndex += 1;
}
nextOperations.splice(insertIndex, 0, moved);
return nextOperations;
};
const getOperationSummary = (operation = {}, index = 0) => {
const mode = operation.mode || 'set';
const modeLabel = OPERATION_MODE_LABEL_MAP[mode] || mode;
@@ -1037,8 +1077,12 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
const [operationSearch, setOperationSearch] = useState('');
const [selectedOperationId, setSelectedOperationId] = useState('');
const [expandedConditionMap, setExpandedConditionMap] = useState({});
const [draggedOperationId, setDraggedOperationId] = useState('');
const [dragOverOperationId, setDragOverOperationId] = useState('');
const [dragOverPosition, setDragOverPosition] = useState('before');
const [templateGroupKey, setTemplateGroupKey] = useState('basic');
const [templatePresetKey, setTemplatePresetKey] = useState('operations_default');
const [headerValueExampleVisible, setHeaderValueExampleVisible] = useState(false);
const [fieldGuideVisible, setFieldGuideVisible] = useState(false);
const [fieldGuideTarget, setFieldGuideTarget] = useState('path');
const [fieldGuideKeyword, setFieldGuideKeyword] = useState('');
@@ -1055,6 +1099,9 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
setOperationSearch('');
setSelectedOperationId(nextState.operations[0]?.id || '');
setExpandedConditionMap({});
setDraggedOperationId('');
setDragOverOperationId('');
setDragOverPosition('before');
if (nextState.visualMode === 'legacy') {
setTemplateGroupKey('basic');
setTemplatePresetKey('legacy_default');
@@ -1062,6 +1109,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
setTemplateGroupKey('basic');
setTemplatePresetKey('operations_default');
}
setHeaderValueExampleVisible(false);
setFieldGuideVisible(false);
setFieldGuideTarget('path');
setFieldGuideKeyword('');
@@ -1583,6 +1631,67 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
setSelectedOperationId(created.id);
};
const resetOperationDragState = useCallback(() => {
setDraggedOperationId('');
setDragOverOperationId('');
setDragOverPosition('before');
}, []);
const moveOperation = useCallback(
(sourceId, targetId, position = 'before') => {
if (!sourceId || !targetId || sourceId === targetId) {
return;
}
setOperations((prev) =>
reorderOperations(prev, sourceId, targetId, position),
);
setSelectedOperationId(sourceId);
},
[],
);
const handleOperationDragStart = useCallback((event, operationId) => {
setDraggedOperationId(operationId);
setSelectedOperationId(operationId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', operationId);
}, []);
const handleOperationDragOver = useCallback(
(event, operationId) => {
event.preventDefault();
if (!draggedOperationId || draggedOperationId === operationId) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const position =
event.clientY - rect.top > rect.height / 2 ? 'after' : 'before';
setDragOverOperationId(operationId);
setDragOverPosition(position);
event.dataTransfer.dropEffect = 'move';
},
[draggedOperationId],
);
const handleOperationDrop = useCallback(
(event, operationId) => {
event.preventDefault();
const sourceId =
draggedOperationId || event.dataTransfer.getData('text/plain');
const position =
dragOverOperationId === operationId ? dragOverPosition : 'before';
moveOperation(sourceId, operationId, position);
resetOperationDragState();
},
[
dragOverOperationId,
dragOverPosition,
draggedOperationId,
moveOperation,
resetOperationDragState,
],
);
const duplicateOperation = (operationId) => {
let insertedId = '';
setOperations((prev) => {
@@ -1941,14 +2050,31 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
);
const isActive =
operation.id === selectedOperationId;
const isDragging =
operation.id === draggedOperationId;
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId &&
draggedOperationId !== operation.id;
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onDragStart={(event) =>
handleOperationDragStart(event, operation.id)
}
onDragOver={(event) =>
handleOperationDragOver(event, operation.id)
}
onDrop={(event) =>
handleOperationDrop(event, operation.id)
}
onDragEnd={resetOperationDragState}
onKeyDown={(event) => {
if (
event.key === 'Enter' ||
@@ -1966,35 +2092,53 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
border: isActive
? '1px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
opacity: isDragging ? 0.6 : 1,
boxShadow: isDropTarget
? dragOverPosition === 'after'
? 'inset 0 -3px 0 var(--semi-color-primary)'
: 'inset 0 3px 0 var(--semi-color-primary)'
: 'none',
}}
>
<div className='flex items-start justify-between gap-2'>
<div>
<Text strong>{`#${index + 1}`}</Text>
<Text
type='tertiary'
size='small'
className='block mt-1'
<div className='flex items-start gap-2 min-w-0'>
<div
className='flex-shrink-0'
style={{
color: 'var(--semi-color-text-2)',
cursor: operations.length > 1 ? 'grab' : 'default',
marginTop: 1,
}}
>
{getOperationSummary(operation, index)}
</Text>
{String(operation.description || '').trim() ? (
<IconMenu />
</div>
<div className='min-w-0'>
<Text strong>{`#${index + 1}`}</Text>
<Text
type='tertiary'
size='small'
className='block mt-1'
style={{
lineHeight: 1.5,
wordBreak: 'break-word',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{operation.description}
{getOperationSummary(operation, index)}
</Text>
) : null}
{String(operation.description || '').trim() ? (
<Text
type='tertiary'
size='small'
className='block mt-1'
style={{
lineHeight: 1.5,
wordBreak: 'break-word',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{operation.description}
</Text>
) : null}
</div>
</div>
<Tag size='small' color='grey'>
{(operation.conditions || []).length}
@@ -2688,15 +2832,35 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
{t(getModeValueLabel(mode))}
</Text>
{mode === 'set_header' ? (
<Button
size='small'
type='tertiary'
onClick={formatSelectedOperationValueAsJson}
>
{t('格式化 JSON')}
</Button>
<Space spacing={6}>
<Button
size='small'
type='tertiary'
onClick={() =>
setHeaderValueExampleVisible(true)
}
>
{t('查看 JSON 示例')}
</Button>
<Button
size='small'
type='tertiary'
onClick={formatSelectedOperationValueAsJson}
>
{t('格式化 JSON')}
</Button>
</Space>
) : null}
</div>
{mode === 'set_header' ? (
<Text
type='tertiary'
size='small'
className='mt-1 mb-2 block'
>
{t('纯字符串会直接覆盖整条请求头,或者点击“查看 JSON 示例”按 token 规则处理。')}
</Text>
) : null}
<TextArea
value={selectedOperation.value_text}
autosize={{ minRows: 1, maxRows: 4 }}
@@ -2707,11 +2871,6 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
})
}
/>
{getModeValueHelp(mode) ? (
<Text type='tertiary' size='small'>
{t(getModeValueHelp(mode))}
</Text>
) : null}
</div>
)
) : null}
@@ -3167,6 +3326,27 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
</Space>
</Modal>
<Modal
title={t('anthropic-beta JSON 示例')}
visible={headerValueExampleVisible}
width={760}
footer={null}
onCancel={() => setHeaderValueExampleVisible(false)}
bodyStyle={{ padding: 16, paddingBottom: 24 }}
>
<Space vertical align='start' spacing={12} style={{ width: '100%' }}>
<Text type='tertiary' size='small'>
{t('下面是带注释的示例,仅用于参考;实际保存时请删除注释。')}
</Text>
<TextArea
value={HEADER_VALUE_JSONC_EXAMPLE}
readOnly
autosize={{ minRows: 16, maxRows: 20 }}
style={{ marginBottom: 8 }}
/>
</Space>
</Modal>
<Modal
title={null}
visible={fieldGuideVisible}
@@ -25,6 +25,7 @@ const PricingDisplaySettings = ({
setShowWithRecharge,
currency,
setCurrency,
siteDisplayType,
showRatio,
setShowRatio,
viewMode,
@@ -34,11 +35,17 @@ const PricingDisplaySettings = ({
loading = false,
t,
}) => {
const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS';
const items = [
{
value: 'recharge',
label: t('充值价格显示'),
},
...(supportsCurrencyDisplay
? [
{
value: 'recharge',
label: t('充值价格显示'),
},
]
: []),
{
value: 'ratio',
label: t('显示倍率'),
@@ -78,7 +85,7 @@ const PricingDisplaySettings = ({
const getActiveValues = () => {
const activeValues = [];
if (showWithRecharge) activeValues.push('recharge');
if (supportsCurrencyDisplay && showWithRecharge) activeValues.push('recharge');
if (showRatio) activeValues.push('ratio');
if (viewMode === 'table') activeValues.push('tableView');
if (tokenUnit === 'K') activeValues.push('tokenUnit');
@@ -98,7 +105,7 @@ const PricingDisplaySettings = ({
t={t}
/>
{showWithRecharge && (
{supportsCurrencyDisplay && showWithRecharge && (
<SelectableButtonGroup
title={t('货币单位')}
items={currencyItems}
@@ -70,6 +70,7 @@ const PricingPage = () => {
groupRatio={pricingData.groupRatio}
usableGroup={pricingData.usableGroup}
currency={pricingData.currency}
siteDisplayType={pricingData.siteDisplayType}
tokenUnit={pricingData.tokenUnit}
displayPrice={pricingData.displayPrice}
showRatio={allProps.showRatio}
@@ -40,6 +40,7 @@ const PricingTopSection = memo(
setShowWithRecharge,
currency,
setCurrency,
siteDisplayType,
showRatio,
setShowRatio,
viewMode,
@@ -68,6 +69,7 @@ const PricingTopSection = memo(
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
siteDisplayType={siteDisplayType}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
@@ -103,6 +105,7 @@ const PricingTopSection = memo(
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
siteDisplayType={siteDisplayType}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
@@ -35,6 +35,7 @@ const SearchActions = memo(
setShowWithRecharge,
currency,
setCurrency,
siteDisplayType,
showRatio,
setShowRatio,
viewMode,
@@ -43,6 +44,8 @@ const SearchActions = memo(
setTokenUnit,
t,
}) => {
const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS';
const handleCopyClick = useCallback(() => {
if (copyText && selectedRowKeys.length > 0) {
copyText(selectedRowKeys);
@@ -91,16 +94,18 @@ const SearchActions = memo(
<Divider layout='vertical' margin='8px' />
{/* 充值价格显示开关 */}
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-600'>{t('充值价格显示')}</span>
<Switch
checked={showWithRecharge}
onChange={setShowWithRecharge}
/>
</div>
{supportsCurrencyDisplay && (
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-600'>{t('充值价格显示')}</span>
<Switch
checked={showWithRecharge}
onChange={setShowWithRecharge}
/>
</div>
)}
{/* 货币单位选择 */}
{showWithRecharge && (
{supportsCurrencyDisplay && showWithRecharge && (
<Select
value={currency}
onChange={setCurrency}
@@ -35,6 +35,7 @@ const ModelDetailSideSheet = ({
modelData,
groupRatio,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
@@ -92,6 +93,7 @@ const ModelDetailSideSheet = ({
modelData={modelData}
groupRatio={groupRatio}
currency={currency}
siteDisplayType={siteDisplayType}
tokenUnit={tokenUnit}
displayPrice={displayPrice}
showRatio={showRatio}
@@ -32,6 +32,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
setShowWithRecharge,
currency,
setCurrency,
siteDisplayType,
handleChange,
setActiveKey,
showRatio,
@@ -77,6 +78,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
setShowWithRecharge={setShowWithRecharge}
currency={currency}
setCurrency={setCurrency}
siteDisplayType={siteDisplayType}
showRatio={showRatio}
setShowRatio={setShowRatio}
viewMode={viewMode}
@@ -28,6 +28,7 @@ const ModelPricingTable = ({
modelData,
groupRatio,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
@@ -57,6 +58,7 @@ const ModelPricingTable = ({
tokenUnit,
displayPrice,
currency,
quotaDisplayType: siteDisplayType,
})
: { inputPrice: '-', outputPrice: '-', price: '-' };
@@ -74,7 +76,7 @@ const ModelPricingTable = ({
: modelData?.quota_type === 1
? t('按次计费')
: '-',
priceItems: getModelPriceItems(priceData, t),
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
};
});
@@ -122,7 +124,7 @@ const ModelPricingTable = ({
});
columns.push({
title: t('价格摘要'),
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
dataIndex: 'priceItems',
render: (items) => (
<div className='space-y-1'>
@@ -67,6 +67,7 @@ const PricingCardView = ({
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
@@ -246,6 +247,7 @@ const PricingCardView = ({
tokenUnit,
displayPrice,
currency,
quotaDisplayType: siteDisplayType,
});
return (
@@ -265,7 +267,7 @@ const PricingCardView = ({
{model.model_name}
</h3>
<div className='flex flex-col gap-1 text-xs mt-1'>
{formatPriceInfo(priceData, t)}
{formatPriceInfo(priceData, t, siteDisplayType)}
</div>
</div>
</div>
@@ -37,6 +37,7 @@ const PricingTable = ({
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
searchValue,
@@ -54,6 +55,7 @@ const PricingTable = ({
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
@@ -66,6 +68,7 @@ const PricingTable = ({
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
@@ -109,6 +109,7 @@ export const getPricingTableColumns = ({
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
@@ -126,6 +127,7 @@ export const getPricingTableColumns = ({
tokenUnit,
displayPrice,
currency,
quotaDisplayType: siteDisplayType,
});
priceDataCache.set(record, cache);
}
@@ -227,12 +229,12 @@ export const getPricingTableColumns = ({
};
const priceColumn = {
title: t('模型价格'),
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('模型价格'),
dataIndex: 'model_price',
...(isMobile ? {} : { fixed: 'right' }),
render: (text, record, index) => {
const priceData = getPriceData(record);
const priceItems = getModelPriceItems(priceData, t);
const priceItems = getModelPriceItems(priceData, t, siteDisplayType);
return (
<div className='space-y-1'>
@@ -29,7 +29,6 @@ const TokensActions = ({
setShowEdit,
batchCopyTokens,
batchDeleteTokens,
copyText,
t,
}) => {
// Modal states
@@ -99,8 +98,7 @@ const TokensActions = ({
<CopyTokensModal
visible={showCopyModal}
onCancel={() => setShowCopyModal(false)}
selectedKeys={selectedKeys}
copyText={copyText}
batchCopyTokens={batchCopyTokens}
t={t}
/>
@@ -108,17 +108,28 @@ const renderGroupColumn = (text, record, t) => {
};
// Render token key column with show/hide and copy functionality
const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
const fullKey = 'sk-' + record.key;
const maskedKey =
'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
const renderTokenKey = (
text,
record,
showKeys,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
) => {
const revealed = !!showKeys[record.id];
const loading = !!loadingTokenKeys[record.id];
const keyValue =
revealed && resolvedTokenKeys[record.id]
? resolvedTokenKeys[record.id]
: record.key || '';
const displayedKey = keyValue ? `sk-${keyValue}` : '';
return (
<div className='w-[200px]'>
<Input
readOnly
value={revealed ? fullKey : maskedKey}
value={displayedKey}
size='small'
suffix={
<div className='flex items-center'>
@@ -127,10 +138,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
size='small'
type='tertiary'
icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
loading={loading}
aria-label='toggle token visibility'
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
setShowKeys((prev) => ({ ...prev, [record.id]: !revealed }));
await toggleTokenVisibility(record);
}}
/>
<Button
@@ -138,10 +150,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
size='small'
type='tertiary'
icon={<IconCopy />}
loading={loading}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
await copyText(fullKey);
await copyTokenKey(record);
}}
/>
</div>
@@ -427,8 +440,10 @@ const renderOperations = (
export const getTokensColumns = ({
t,
showKeys,
setShowKeys,
copyText,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
manageToken,
onOpenLink,
setEditingToken,
@@ -461,7 +476,15 @@ export const getTokensColumns = ({
title: t('密钥'),
key: 'token_key',
render: (text, record) =>
renderTokenKey(text, record, showKeys, setShowKeys, copyText),
renderTokenKey(
text,
record,
showKeys,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
),
},
{
title: t('可用模型'),
@@ -39,8 +39,10 @@ const TokensTable = (tokensData) => {
rowSelection,
handleRow,
showKeys,
setShowKeys,
copyText,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
manageToken,
onOpenLink,
setEditingToken,
@@ -54,8 +56,10 @@ const TokensTable = (tokensData) => {
return getTokensColumns({
t,
showKeys,
setShowKeys,
copyText,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
manageToken,
onOpenLink,
setEditingToken,
@@ -65,8 +69,10 @@ const TokensTable = (tokensData) => {
}, [
t,
showKeys,
setShowKeys,
copyText,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
manageToken,
onOpenLink,
setEditingToken,
+10 -4
View File
@@ -58,6 +58,7 @@ function TokensPage() {
t: (k) => k,
selectedModel: '',
prefillKey: '',
fetchTokenKey: async () => '',
});
const [modelOptions, setModelOptions] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
@@ -74,6 +75,7 @@ function TokensPage() {
t: tokensData.t,
selectedModel,
prefillKey,
fetchTokenKey: tokensData.fetchTokenKey,
};
}, [
tokensData.tokens,
@@ -81,6 +83,7 @@ function TokensPage() {
tokensData.t,
selectedModel,
prefillKey,
tokensData.fetchTokenKey,
]);
const loadModels = async () => {
@@ -198,13 +201,14 @@ function TokensPage() {
openCCSwitchModalRef.current = openCCSwitchModal;
// Prefill to Fluent handler
const handlePrefillToFluent = () => {
const handlePrefillToFluent = async () => {
const {
tokens,
selectedKeys,
t,
selectedModel: chosenModel,
prefillKey: overrideKey,
fetchTokenKey,
} = latestRef.current;
const container = document.getElementById('fluent-new-api-container');
if (!container) {
@@ -241,7 +245,11 @@ function TokensPage() {
Toast.warning(t('没有可用令牌用于填充'));
return;
}
apiKeyToUse = 'sk-' + token.key;
try {
apiKeyToUse = 'sk-' + (await fetchTokenKey(token));
} catch (_) {
return;
}
}
const payload = {
@@ -351,7 +359,6 @@ function TokensPage() {
setShowEdit,
batchCopyTokens,
batchDeleteTokens,
copyText,
// Filters state
formInitValues,
@@ -401,7 +408,6 @@ function TokensPage() {
setShowEdit={setShowEdit}
batchCopyTokens={batchCopyTokens}
batchDeleteTokens={batchDeleteTokens}
copyText={copyText}
t={t}
/>
@@ -116,8 +116,7 @@ export default function CCSwitchModal({
Toast.warning(t('请选择主模型'));
return;
}
const apiKey = 'sk-' + tokenKey;
const url = buildCCSwitchURL(app, name, models, apiKey);
const url = buildCCSwitchURL(app, name, models, 'sk-' + tokenKey);
window.open(url, '_blank');
onClose();
};
@@ -20,24 +20,21 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Modal, Button, Space } from '@douyinfe/semi-ui';
const CopyTokensModal = ({ visible, onCancel, selectedKeys, copyText, t }) => {
const CopyTokensModal = ({
visible,
onCancel,
batchCopyTokens,
t,
}) => {
// Handle copy with name and key format
const handleCopyWithName = async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content += selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
await batchCopyTokens('name+key');
onCancel();
};
// Handle copy with key only format
const handleCopyKeyOnly = async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content += 'sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
await batchCopyTokens('key-only');
onCancel();
};
@@ -25,21 +25,14 @@ import {
Tooltip,
Popover,
Typography,
Button
} from '@douyinfe/semi-ui';
import {
timestamp2string,
renderGroup,
renderQuota,
stringToColor,
getLogOther,
renderModelTag,
renderClaudeLogContent,
renderLogContent,
renderModelPriceSimple,
renderAudioModelPrice,
renderClaudeModelPrice,
renderModelPrice,
} from '../../../helpers';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Route, Sparkles } from 'lucide-react';
@@ -330,6 +323,142 @@ function getPromptCacheSummary(other) {
};
}
function normalizeDetailText(detail) {
return String(detail || '')
.replace(/\n\r/g, '\n')
.replace(/\r\n/g, '\n');
}
function getUsageLogGroupSummary(groupRatio, userGroupRatio, t) {
const parsedUserGroupRatio = Number(userGroupRatio);
const useUserGroupRatio =
Number.isFinite(parsedUserGroupRatio) && parsedUserGroupRatio !== -1;
const ratio = useUserGroupRatio ? userGroupRatio : groupRatio;
if (ratio === undefined || ratio === null || ratio === '') {
return '';
}
return `${useUserGroupRatio ? t('专属倍率') : t('分组')} ${formatRatio(ratio)}x`;
}
function renderCompactDetailSummary(summarySegments) {
const segments = Array.isArray(summarySegments)
? summarySegments.filter((segment) => segment?.text)
: [];
if (!segments.length) {
return null;
}
return (
<div
style={{
maxWidth: 180,
lineHeight: 1.35,
}}
>
{segments.map((segment, index) => (
<Typography.Text
key={`${segment.text}-${index}`}
type={segment.tone === 'secondary' ? 'tertiary' : undefined}
size={segment.tone === 'secondary' ? 'small' : undefined}
style={{
display: 'block',
maxWidth: '100%',
fontSize: 12,
marginTop: index === 0 ? 0 : 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{segment.text}
</Typography.Text>
))}
</div>
);
}
function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
const other = getLogOther(record.other);
if (record.type === 6) {
return {
segments: [{ text: t('异步任务退款'), tone: 'primary' }],
};
}
if (other == null || record.type !== 2) {
return null;
}
if (
other?.violation_fee === true ||
Boolean(other?.violation_fee_code) ||
Boolean(other?.violation_fee_marker)
) {
const feeQuota = other?.fee_quota ?? record?.quota;
const groupText = getUsageLogGroupSummary(
other?.group_ratio,
other?.user_group_ratio,
t,
);
return {
segments: [
groupText ? { text: groupText, tone: 'primary' } : null,
{ text: t('违规扣费'), tone: 'primary' },
{
text: `${t('扣费')}${renderQuota(feeQuota, 6)}`,
tone: 'secondary',
},
text ? { text: `${t('详情')}${text}`, tone: 'secondary' } : null,
].filter(Boolean),
};
}
return {
segments: other?.claude
? renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'claude',
billingDisplayMode,
'segments',
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
0,
1.0,
0,
1.0,
0,
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'openai',
billingDisplayMode,
'segments',
),
};
}
export const getLogsColumns = ({
t,
COLUMN_KEYS,
@@ -337,6 +466,7 @@ export const getLogsColumns = ({
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
billingDisplayMode = 'price',
}) => {
return [
{
@@ -374,7 +504,10 @@ export const getLogsColumns = ({
}
return isAdminUser &&
(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? (
(record.type === 0 ||
record.type === 2 ||
record.type === 5 ||
record.type === 6) ? (
<Space>
<span style={{ position: 'relative', display: 'inline-block' }}>
<Tooltip content={record.channel_name || t('未知渠道')}>
@@ -465,7 +598,10 @@ export const getLogsColumns = ({
title: t('令牌'),
dataIndex: 'token_name',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
return record.type === 0 ||
record.type === 2 ||
record.type === 5 ||
record.type === 6 ? (
<div>
<Tag
color='grey'
@@ -488,7 +624,12 @@ export const getLogsColumns = ({
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => {
if (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) {
if (
record.type === 0 ||
record.type === 2 ||
record.type === 5 ||
record.type === 6
) {
if (record.group) {
return <>{renderGroup(record.group)}</>;
} else {
@@ -528,7 +669,10 @@ export const getLogsColumns = ({
title: t('模型'),
dataIndex: 'model_name',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
return record.type === 0 ||
record.type === 2 ||
record.type === 5 ||
record.type === 6 ? (
<>{renderModelName(record, copyText, t)}</>
) : (
<></>
@@ -595,7 +739,10 @@ export const getLogsColumns = ({
cacheText = `${t('缓存写')} ${formatTokenCount(cacheSummary.cacheWriteTokens)}`;
}
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
return record.type === 0 ||
record.type === 2 ||
record.type === 5 ||
record.type === 6 ? (
<div
style={{
display: 'inline-flex',
@@ -629,7 +776,10 @@ export const getLogsColumns = ({
dataIndex: 'completion_tokens',
render: (text, record, index) => {
return parseInt(text) > 0 &&
(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? (
(record.type === 0 ||
record.type === 2 ||
record.type === 5 ||
record.type === 6) ? (
<>{<span> {text} </span>}</>
) : (
<></>
@@ -641,7 +791,14 @@ export const getLogsColumns = ({
title: t('花费'),
dataIndex: 'quota',
render: (text, record, index) => {
if (!(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6)) {
if (
!(
record.type === 0 ||
record.type === 2 ||
record.type === 5 ||
record.type === 6
)
) {
return <></>;
}
const other = getLogOther(record.other);
@@ -708,9 +865,9 @@ export const getLogsColumns = ({
}
if (other.admin_info !== undefined) {
if (
other.admin_info.use_channel !== null &&
other.admin_info.use_channel !== undefined &&
other.admin_info.use_channel !== ''
other.admin_info.use_channel !== null &&
other.admin_info.use_channel !== undefined &&
other.admin_info.use_channel !== ''
) {
let useChannel = other.admin_info.use_channel;
let useChannelStr = useChannel.join('->');
@@ -726,19 +883,16 @@ export const getLogsColumns = ({
title: t('详情'),
dataIndex: 'content',
fixed: 'right',
width: 200,
render: (text, record, index) => {
let other = getLogOther(record.other);
if (record.type === 6) {
return (
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ maxWidth: 240 }}
>
{t('异步任务退款')}
</Typography.Paragraph>
);
}
if (other == null || record.type !== 2) {
const detailSummary = getUsageLogDetailSummary(
record,
text,
billingDisplayMode,
t,
);
if (!detailSummary) {
return (
<Typography.Paragraph
ellipsis={{
@@ -748,95 +902,14 @@ export const getLogsColumns = ({
opts: { style: { width: 240 } },
},
}}
style={{ maxWidth: 240 }}
style={{ maxWidth: 200, marginBottom: 0 }}
>
{text}
</Typography.Paragraph>
);
}
if (
other?.violation_fee === true ||
Boolean(other?.violation_fee_code) ||
Boolean(other?.violation_fee_marker)
) {
const feeQuota = other?.fee_quota ?? record?.quota;
const ratioText = formatRatio(other?.group_ratio);
const summary = [
t('违规扣费'),
`${t('分组倍率')}${ratioText}`,
`${t('扣费')}${renderQuota(feeQuota, 6)}`,
text ? `${t('详情')}${text}` : null,
]
.filter(Boolean)
.join('\n');
return (
<Typography.Paragraph
ellipsis={{
rows: 2,
showTooltip: {
type: 'popover',
opts: { style: { width: 240 } },
},
}}
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
>
{summary}
</Typography.Paragraph>
);
}
let content = other?.claude
? renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'claude',
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
0,
1.0,
0,
1.0,
0,
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'openai',
);
return (
<Typography.Paragraph
ellipsis={{
rows: 3,
}}
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
>
{content}
</Typography.Paragraph>
);
return renderCompactDetailSummary(detailSummary.segments);
},
},
];
@@ -43,6 +43,7 @@ const LogsTable = (logsData) => {
openChannelAffinityUsageCacheModal,
hasExpandableRows,
isAdminUser,
billingDisplayMode,
t,
COLUMN_KEYS,
} = logsData;
@@ -56,6 +57,7 @@ const LogsTable = (logsData) => {
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
billingDisplayMode,
});
}, [
t,
@@ -64,6 +66,7 @@ const LogsTable = (logsData) => {
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
billingDisplayMode,
]);
// Filter columns based on visibility settings
@@ -99,7 +102,7 @@ const LogsTable = (logsData) => {
loading={loading}
scroll={compactMode ? undefined : { x: 'max-content' }}
className='rounded-xl overflow-hidden'
size='middle'
size='small'
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
@@ -0,0 +1,54 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Typography } from '@douyinfe/semi-ui';
const { Text } = Typography;
const ParamOverrideEntry = ({ count, onOpen, t }) => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
}}
>
<Text
type='tertiary'
size='small'
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{t('{{count}} 项操作', { count })}
</Text>
<Text
link
size='small'
style={{ fontWeight: 600 }}
onClick={onOpen}
>
{t('查看详情')}
</Text>
</div>
);
};
export default React.memo(ParamOverrideEntry);
@@ -25,6 +25,7 @@ import LogsFilters from './UsageLogsFilters';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import UserInfoModal from './modals/UserInfoModal';
import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
import ParamOverrideModal from './modals/ParamOverrideModal';
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
@@ -39,6 +40,7 @@ const LogsPage = () => {
<ColumnSelectorModal {...logsData} />
<UserInfoModal {...logsData} />
<ChannelAffinityUsageCacheModal {...logsData} />
<ParamOverrideModal {...logsData} />
{/* Main Content */}
<CardPro
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
import { Modal, Button, Checkbox, RadioGroup, Radio } from '@douyinfe/semi-ui';
import { getLogsColumns } from '../UsageLogsColumnDefs';
const ColumnSelectorModal = ({
@@ -28,12 +28,22 @@ const ColumnSelectorModal = ({
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
billingDisplayMode,
setBillingDisplayMode,
COLUMN_KEYS,
isAdminUser,
copyText,
showUserInfoFunc,
t,
}) => {
const handleBillingDisplayModeChange = (eventOrValue) => {
setBillingDisplayMode(eventOrValue?.target?.value ?? eventOrValue);
};
const isTokensDisplay =
typeof localStorage !== 'undefined' &&
localStorage.getItem('quota_display_type') === 'TOKENS';
// Get all columns for display in selector
const allColumns = getLogsColumns({
t,
@@ -41,6 +51,7 @@ const ColumnSelectorModal = ({
copyText,
showUserInfoFunc,
isAdminUser,
billingDisplayMode,
});
return (
@@ -61,6 +72,21 @@ const ColumnSelectorModal = ({
}
>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t('计费显示模式')}</div>
<RadioGroup
type='button'
value={billingDisplayMode}
onChange={handleBillingDisplayModeChange}
>
<Radio value='price'>
{isTokensDisplay ? t('价格模式') : t('价格模式(默认)')}
</Radio>
<Radio value='ratio'>
{isTokensDisplay ? t('倍率模式(默认)') : t('倍率模式')}
</Radio>
</RadioGroup>
</div>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
@@ -0,0 +1,272 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo } from 'react';
import {
Modal,
Button,
Empty,
Divider,
Typography,
} from '@douyinfe/semi-ui';
import { IconCopy } from '@douyinfe/semi-icons';
import { copy, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
const parseAuditLine = (line) => {
if (typeof line !== 'string') {
return null;
}
const firstSpaceIndex = line.indexOf(' ');
if (firstSpaceIndex <= 0) {
return { action: line, content: line };
}
return {
action: line.slice(0, firstSpaceIndex),
content: line.slice(firstSpaceIndex + 1),
};
};
const getActionLabel = (action, t) => {
switch ((action || '').toLowerCase()) {
case 'set':
return t('设置');
case 'delete':
return t('删除');
case 'copy':
return t('复制');
case 'move':
return t('移动');
case 'append':
return t('追加');
case 'prepend':
return t('前置');
case 'trim_prefix':
return t('去前缀');
case 'trim_suffix':
return t('去后缀');
case 'ensure_prefix':
return t('保前缀');
case 'ensure_suffix':
return t('保后缀');
case 'trim_space':
return t('去空格');
case 'to_lower':
return t('转小写');
case 'to_upper':
return t('转大写');
case 'replace':
return t('替换');
case 'regex_replace':
return t('正则替换');
case 'set_header':
return t('设请求头');
case 'delete_header':
return t('删请求头');
case 'copy_header':
return t('复制请求头');
case 'move_header':
return t('移动请求头');
case 'pass_headers':
return t('透传请求头');
case 'sync_fields':
return t('同步字段');
case 'return_error':
return t('返回错误');
default:
return action;
}
};
const ParamOverrideModal = ({
showParamOverrideModal,
setShowParamOverrideModal,
paramOverrideTarget,
t,
}) => {
const lines = Array.isArray(paramOverrideTarget?.lines)
? paramOverrideTarget.lines
: [];
const parsedLines = useMemo(() => {
return lines.map(parseAuditLine);
}, [lines]);
const copyAll = async () => {
const content = lines.join('\n');
if (!content) {
return;
}
if (await copy(content)) {
showSuccess(t('参数覆盖已复制'));
return;
}
showError(t('无法复制到剪贴板,请手动复制'));
};
return (
<Modal
title={t('参数覆盖详情')}
visible={showParamOverrideModal}
onCancel={() => setShowParamOverrideModal(false)}
footer={null}
centered
closable
maskClosable
width={640}
>
<div style={{ padding: '8px 20px 20px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
marginBottom: 10,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ marginBottom: 4 }}>
<Text style={{ fontWeight: 600 }}>
{t('{{count}} 项操作', { count: lines.length })}
</Text>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
fontSize: 12,
color: 'var(--semi-color-text-2)',
}}
>
{paramOverrideTarget?.modelName ? (
<Text type='tertiary' size='small'>
{paramOverrideTarget.modelName}
</Text>
) : null}
{paramOverrideTarget?.requestId ? (
<Text type='tertiary' size='small'>
{t('Request ID')}: {paramOverrideTarget.requestId}
</Text>
) : null}
{paramOverrideTarget?.requestPath ? (
<Text type='tertiary' size='small'>
{t('请求路径')}: {paramOverrideTarget.requestPath}
</Text>
) : null}
</div>
</div>
<Button
icon={<IconCopy />}
theme='borderless'
type='tertiary'
size='small'
onClick={copyAll}
disabled={lines.length === 0}
>
{t('复制')}
</Button>
</div>
<Divider margin='12px' />
{lines.length === 0 ? (
<Empty
description={t('暂无参数覆盖记录')}
style={{ padding: '24px 0 8px' }}
/>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
maxHeight: '56vh',
overflowY: 'auto',
paddingRight: 2,
}}
>
{parsedLines.map((item, index) => {
if (!item) {
return null;
}
return (
<div
key={`${item.action}-${index}`}
style={{
padding: '10px 12px',
borderRadius: 10,
border: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-fill-0)',
display: 'flex',
gap: 12,
alignItems: 'flex-start',
}}
>
<div
style={{
flex: '0 0 auto',
minWidth: 74,
}}
>
<Text
style={{
display: 'inline-block',
fontSize: 11,
fontWeight: 700,
lineHeight: '20px',
padding: '0 8px',
borderRadius: 999,
background: 'rgba(var(--semi-blue-5), 0.12)',
color: 'var(--semi-color-primary)',
}}
>
{getActionLabel(item.action, t)}
</Text>
</div>
<Text
style={{
flex: 1,
minWidth: 0,
fontFamily:
'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace',
fontSize: 12,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--semi-color-text-0)',
}}
>
{item.content}
</Text>
</div>
);
})}
</div>
)}
</div>
</Modal>
);
};
export default ParamOverrideModal;
+50 -11
View File
@@ -87,6 +87,9 @@ const RechargeCard = ({
statusLoading,
topupInfo,
onOpenHistory,
enableWaffoTopUp,
waffoTopUp,
waffoPayMethods,
subscriptionLoading = false,
subscriptionPlans = [],
billingPreference,
@@ -224,19 +227,19 @@ const RechargeCard = ({
<div className='py-8 flex justify-center'>
<Spin size='large' />
</div>
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? (
<Form
getFormApi={(api) => (onlineFormApiRef.current = api)}
initValues={{ topUpCount: topUpCount }}
>
<div className='space-y-6'>
{(enableOnlineTopUp || enableStripeTopUp) && (
{(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && (
<Row gutter={12}>
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.InputNumber
field='topUpCount'
label={t('充值数量')}
disabled={!enableOnlineTopUp && !enableStripeTopUp}
disabled={!enableOnlineTopUp && !enableStripeTopUp && !enableWaffoTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
@@ -288,11 +291,11 @@ const RechargeCard = ({
style={{ width: '100%' }}
/>
</Col>
{payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && (
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
<Form.Slot label={t('选择支付方式')}>
{payMethods && payMethods.length > 0 ? (
<Space wrap>
{payMethods.map((payMethod) => {
{payMethods.filter(m => m.type !== 'waffo').map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const disabled =
@@ -352,17 +355,13 @@ const RechargeCard = ({
);
})}
</Space>
) : (
<div className='text-gray-500 text-sm p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300'>
{t('暂无可用的支付方式,请联系管理员配置')}
</div>
)}
</Form.Slot>
</Col>
)}
</Row>
)}
{(enableOnlineTopUp || enableStripeTopUp) && (
{(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && (
<Form.Slot
label={
<div className='flex items-center gap-2'>
@@ -483,6 +482,46 @@ const RechargeCard = ({
</Form.Slot>
)}
{/* Waffo 充值区域 */}
{enableWaffoTopUp &&
waffoPayMethods &&
waffoPayMethods.length > 0 && (
<Form.Slot label={t('Waffo 充值')}>
<Space wrap>
{waffoPayMethods.map((method, index) => (
<Button
key={index}
theme='outline'
type='tertiary'
onClick={() => waffoTopUp(index)}
loading={paymentLoading}
icon={
method.icon ? (
<img
src={method.icon}
alt={method.name}
style={{
width: 36,
height: 36,
objectFit: 'contain',
}}
/>
) : (
<CreditCard
size={18}
color='var(--semi-color-text-2)'
/>
)
}
className='!rounded-lg !px-4 !py-2'
>
{method.name}
</Button>
))}
</Space>
</Form.Slot>
)}
{/* Creem 充值区域 */}
{enableCreemTopUp && creemProducts.length > 0 && (
<Form.Slot label={t('Creem 充值')}>
+61 -10
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useContext, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
API,
showError,
@@ -41,6 +42,7 @@ import TopupHistoryModal from './modals/TopupHistoryModal';
const TopUp = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [statusState] = useContext(StatusContext);
@@ -69,6 +71,11 @@ const TopUp = () => {
const [creemOpen, setCreemOpen] = useState(false);
const [selectedCreemProduct, setSelectedCreemProduct] = useState(null);
// Waffo
const [enableWaffoTopUp, setEnableWaffoTopUp] = useState(false);
const [waffoPayMethods, setWaffoPayMethods] = useState([]);
const [waffoMinTopUp, setWaffoMinTopUp] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const [payWay, setPayWay] = useState('');
@@ -256,7 +263,6 @@ const TopUp = () => {
showError(res);
}
} catch (err) {
console.log(err);
showError(t('支付请求失败'));
} finally {
setOpen(false);
@@ -302,7 +308,6 @@ const TopUp = () => {
showError(res);
}
} catch (err) {
console.log(err);
showError(t('支付请求失败'));
} finally {
setCreemOpen(false);
@@ -310,6 +315,37 @@ const TopUp = () => {
}
};
const waffoTopUp = async (payMethodIndex) => {
try {
if (topUpCount < waffoMinTopUp) {
showError(t('充值数量不能小于') + waffoMinTopUp);
return;
}
setPaymentLoading(true);
const requestBody = {
amount: parseInt(topUpCount),
};
if (payMethodIndex != null) {
requestBody.pay_method_index = payMethodIndex;
}
const res = await API.post('/api/user/waffo/pay', requestBody);
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success' && data?.payment_url) {
window.open(data.payment_url, '_blank');
} else {
showError(data || t('支付请求失败'));
}
} else {
showError(res);
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaymentLoading(false);
}
};
const processCreemCallback = (data) => {
// Stripe
window.open(data.checkout_url, '_blank');
@@ -449,17 +485,21 @@ const TopUp = () => {
? data.min_topup
: enableStripeTopUp
? data.stripe_min_topup
: 1;
: data.enable_waffo_topup
? data.waffo_min_topup
: 1;
setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp);
setEnableCreemTopUp(enableCreemTopUp);
const enableWaffoTopUp = data.enable_waffo_topup || false;
setEnableWaffoTopUp(enableWaffoTopUp);
setWaffoPayMethods(data.waffo_pay_methods || []);
setWaffoMinTopUp(data.waffo_min_topup || 1);
setMinTopUp(minTopUpValue);
setTopUpCount(minTopUpValue);
// Creem
try {
console.log(' data is ?', data);
console.log(' creem products is ?', data.creem_products);
const products = JSON.parse(data.creem_products || '[]');
setCreemProducts(products);
} catch (e) {
@@ -474,7 +514,6 @@ const TopUp = () => {
//
getAmount(minTopUpValue);
} catch (e) {
console.log('解析支付方式失败:', e);
setPayMethods([]);
}
@@ -487,10 +526,10 @@ const TopUp = () => {
setPresetAmounts(customPresets);
}
} else {
console.error('获取充值配置失败:', data);
showError(data || t('获取充值配置失败'));
}
} catch (error) {
console.error('获取充值配置异常:', error);
showError(t('获取充值配置异常'));
}
};
@@ -531,6 +570,15 @@ const TopUp = () => {
showSuccess(t('邀请链接已复制到剪切板'));
};
// URL
useEffect(() => {
if (searchParams.get('show_history') === 'true') {
setOpenHistory(true);
searchParams.delete('show_history');
setSearchParams(searchParams, { replace: true });
}
}, []);
useEffect(() => {
//
getUserQuota().then();
@@ -587,7 +635,7 @@ const TopUp = () => {
showError(res);
}
} catch (err) {
console.log(err);
// amount fetch failed silently
}
setAmountLoading(false);
};
@@ -613,7 +661,7 @@ const TopUp = () => {
showError(res);
}
} catch (err) {
console.log(err);
// amount fetch failed silently
} finally {
setAmountLoading(false);
}
@@ -740,6 +788,9 @@ const TopUp = () => {
enableCreemTopUp={enableCreemTopUp}
creemProducts={creemProducts}
creemPreTopUp={creemPreTopUp}
enableWaffoTopUp={enableWaffoTopUp}
waffoTopUp={waffoTopUp}
waffoPayMethods={waffoPayMethods}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount}
@@ -37,13 +37,13 @@ import { IconSearch } from '@douyinfe/semi-icons';
import { API, timestamp2string } from '../../../helpers';
import { isAdmin } from '../../../helpers/utils';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
const { Text } = Typography;
//
const STATUS_CONFIG = {
success: { type: 'success', key: '成功' },
pending: { type: 'warning', key: '待支付' },
failed: { type: 'danger', key: '失败' },
expired: { type: 'danger', key: '已过期' },
};
@@ -51,6 +51,7 @@ const STATUS_CONFIG = {
const PAYMENT_METHOD_MAP = {
stripe: 'Stripe',
creem: 'Creem',
waffo: 'Waffo',
alipay: '支付宝',
wxpay: '微信',
};
@@ -62,7 +63,6 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState('');
const isMobile = useIsMobile();
const loadTopups = async (currentPage, currentPageSize) => {
@@ -82,7 +82,6 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
Toast.error({ content: message || t('加载失败') });
}
} catch (error) {
console.error('Load topups error:', error);
Toast.error({ content: t('加载账单失败') });
} finally {
setLoading(false);
@@ -214,17 +213,21 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
title: t('操作'),
key: 'action',
render: (_, record) => {
if (record.status !== 'pending') return null;
return (
<Button
size='small'
type='primary'
theme='outline'
onClick={() => confirmAdminComplete(record.trade_no)}
>
{t('补单')}
</Button>
);
const actions = [];
if (record.status === 'pending') {
actions.push(
<Button
key="complete"
size='small'
type='primary'
theme='outline'
onClick={() => confirmAdminComplete(record.trade_no)}
>
{t('补单')}
</Button>
);
}
return actions.length > 0 ? <>{actions}</> : null;
},
});
}
+3
View File
@@ -40,6 +40,9 @@ export const UserProvider = ({ children }) => {
if (normalizedLanguage && normalizedLanguage !== i18n.language) {
i18n.changeLanguage(normalizedLanguage);
}
if (normalizedLanguage) {
localStorage.setItem('i18nextLng', normalizedLanguage);
}
} catch (e) {
// Ignore parse errors
}
+38 -18
View File
@@ -36,6 +36,20 @@ export let API = axios.create({
},
});
function redirectToOAuthUrl(url, options = {}) {
const { openInNewTab = false } = options;
const targetUrl = typeof url === 'string' ? url : url.toString();
if (openInNewTab) {
window.open(targetUrl, '_blank');
return;
}
window.location.assign(targetUrl);
}
function patchAPIInstance(instance) {
const originalGet = instance.get.bind(instance);
const inFlightGetRequests = new Map();
@@ -249,7 +263,7 @@ export async function onDiscordOAuthClicked(client_id, options = {}) {
const redirect_uri = `${window.location.origin}/oauth/discord`;
const response_type = 'code';
const scope = 'identify+openid';
window.open(
redirectToOAuthUrl(
`https://discord.com/oauth2/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`,
);
}
@@ -268,17 +282,13 @@ export async function onOIDCClicked(
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
if (openInNewTab) {
window.open(url.toString(), '_blank');
} else {
window.location.href = url.toString();
}
redirectToOAuthUrl(url, { openInNewTab });
}
export async function onGitHubOAuthClicked(github_client_id, options = {}) {
const state = await prepareOAuthState(options);
if (!state) return;
window.open(
redirectToOAuthUrl(
`https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
);
}
@@ -289,7 +299,7 @@ export async function onLinuxDOOAuthClicked(
) {
const state = await prepareOAuthState(options);
if (!state) return;
window.open(
redirectToOAuthUrl(
`https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
);
}
@@ -307,29 +317,39 @@ export async function onLinuxDOOAuthClicked(
export async function onCustomOAuthClicked(provider, options = {}) {
const state = await prepareOAuthState(options);
if (!state) return;
try {
const redirect_uri = `${window.location.origin}/oauth/${provider.slug}`;
// Check if authorization_endpoint is a full URL or relative path
let authUrl;
if (provider.authorization_endpoint.startsWith('http://') ||
provider.authorization_endpoint.startsWith('https://')) {
if (
provider.authorization_endpoint.startsWith('http://') ||
provider.authorization_endpoint.startsWith('https://')
) {
authUrl = new URL(provider.authorization_endpoint);
} else {
// Relative path - this is a configuration error, show error message
console.error('Custom OAuth authorization_endpoint must be a full URL:', provider.authorization_endpoint);
showError('OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)');
console.error(
'Custom OAuth authorization_endpoint must be a full URL:',
provider.authorization_endpoint,
);
showError(
'OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)',
);
return;
}
authUrl.searchParams.set('client_id', provider.client_id);
authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', provider.scopes || 'openid profile email');
authUrl.searchParams.set(
'scope',
provider.scopes || 'openid profile email',
);
authUrl.searchParams.set('state', state);
window.open(authUrl.toString());
redirectToOAuthUrl(authUrl);
} catch (error) {
console.error('Failed to initiate custom OAuth:', error);
showError('OAuth 登录失败:' + (error.message || '未知错误'));
+1403 -538
View File
File diff suppressed because it is too large Load Diff
+22 -3
View File
@@ -20,8 +20,22 @@ For commercial licensing, please contact support@quantumnous.com
import { API } from './api';
/**
* 获取可用的token keys
* @returns {Promise<string[]>} 返回active状态的token key数组
* 按需获取单个令牌的真实 key
* @param {number|string} tokenId
* @returns {Promise<string>} 返回不带 sk- 前缀的真实 token key
*/
export async function fetchTokenKey(tokenId) {
const response = await API.post(`/api/token/${tokenId}/key`);
const { success, data, message } = response.data || {};
if (!success || !data?.key) {
throw new Error(message || 'Failed to fetch token key');
}
return data.key;
}
/**
* 获取可用的 token keys
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
*/
export async function fetchTokenKeys() {
try {
@@ -31,7 +45,12 @@ export async function fetchTokenKeys() {
const tokenItems = Array.isArray(data) ? data : data.items || [];
const activeTokens = tokenItems.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
const keyResults = await Promise.allSettled(
activeTokens.map((token) => fetchTokenKey(token.id)),
);
return keyResults
.filter((result) => result.status === 'fulfilled' && result.value)
.map((result) => result.value);
} catch (error) {
console.error('Error fetching token keys:', error);
return [];
+86 -9
View File
@@ -615,6 +615,7 @@ export const calculateModelPrice = ({
tokenUnit,
displayPrice,
currency,
quotaDisplayType = 'USD',
precision = 4,
}) => {
// 1. 使
@@ -647,9 +648,34 @@ export const calculateModelPrice = ({
// 2.
if (record.quota_type === 0) {
//
const isTokensDisplay = quotaDisplayType === 'TOKENS';
const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
const hasRatioValue = (value) =>
value !== undefined &&
value !== null &&
value !== '' &&
Number.isFinite(Number(value));
const formatRatio = (value) =>
hasRatioValue(value) ? Number(Number(value).toFixed(6)) : null;
if (isTokensDisplay) {
return {
inputRatio: formatRatio(record.model_ratio),
completionRatio: formatRatio(record.completion_ratio),
cacheRatio: formatRatio(record.cache_ratio),
createCacheRatio: formatRatio(record.create_cache_ratio),
imageRatio: formatRatio(record.image_ratio),
audioInputRatio: formatRatio(record.audio_ratio),
audioOutputRatio: formatRatio(record.audio_completion_ratio),
isPerToken: true,
isTokensDisplay: true,
usedGroup,
usedGroupRatio,
};
}
let symbol = '$';
if (currency === 'CNY') {
@@ -675,12 +701,6 @@ export const calculateModelPrice = ({
return `${symbol}${numericPrice.toFixed(precision)}`;
};
const hasRatioValue = (value) =>
value !== undefined &&
value !== null &&
value !== '' &&
Number.isFinite(Number(value));
const inputPrice = formatTokenPrice(inputRatioPriceUSD);
const audioInputPrice = hasRatioValue(record.audio_ratio)
? formatTokenPrice(inputRatioPriceUSD * Number(record.audio_ratio))
@@ -711,6 +731,7 @@ export const calculateModelPrice = ({
: null,
unitLabel,
isPerToken: true,
isTokensDisplay: false,
usedGroup,
usedGroupRatio,
};
@@ -724,6 +745,7 @@ export const calculateModelPrice = ({
return {
price: displayVal,
isPerToken: false,
isTokensDisplay: false,
usedGroup,
usedGroupRatio,
};
@@ -733,13 +755,68 @@ export const calculateModelPrice = ({
return {
price: '-',
isPerToken: false,
isTokensDisplay: false,
usedGroup,
usedGroupRatio,
};
};
export const getModelPriceItems = (priceData, t) => {
export const getModelPriceItems = (
priceData,
t,
quotaDisplayType = 'USD',
) => {
if (priceData.isPerToken) {
if (quotaDisplayType === 'TOKENS' || priceData.isTokensDisplay) {
return [
{
key: 'input-ratio',
label: t('输入倍率'),
value: priceData.inputRatio,
suffix: 'x',
},
{
key: 'completion-ratio',
label: t('补全倍率'),
value: priceData.completionRatio,
suffix: 'x',
},
{
key: 'cache-ratio',
label: t('缓存读取倍率'),
value: priceData.cacheRatio,
suffix: 'x',
},
{
key: 'create-cache-ratio',
label: t('缓存创建倍率'),
value: priceData.createCacheRatio,
suffix: 'x',
},
{
key: 'image-ratio',
label: t('图片输入倍率'),
value: priceData.imageRatio,
suffix: 'x',
},
{
key: 'audio-input-ratio',
label: t('音频输入倍率'),
value: priceData.audioInputRatio,
suffix: 'x',
},
{
key: 'audio-output-ratio',
label: t('音频补全倍率'),
value: priceData.audioOutputRatio,
suffix: 'x',
},
].filter(
(item) =>
item.value !== null && item.value !== undefined && item.value !== '',
);
}
const unitSuffix = ` / 1${priceData.unitLabel} Tokens`;
return [
{
@@ -798,8 +875,8 @@ export const getModelPriceItems = (priceData, t) => {
};
//
export const formatPriceInfo = (priceData, t) => {
const items = getModelPriceItems(priceData, t);
export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
const items = getModelPriceItems(priceData, t, quotaDisplayType);
return (
<>
{items.map((item) => (
+23 -12
View File
@@ -150,7 +150,9 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const handleLanguageChange = useCallback(
async (lang) => {
// Change language immediately for responsive UX
const previousLang = normalizeLanguage(i18n.language);
i18n.changeLanguage(lang);
localStorage.setItem('i18nextLng', lang);
// If user is logged in, save preference to backend
if (userState?.user?.id) {
@@ -159,25 +161,34 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
language: lang,
});
if (res.data.success) {
// Update user context with new setting
// Keep user preference and local cache in sync so route changes
// don't reapply an older remembered language.
let settings = {};
if (userState?.user?.setting) {
try {
const settings = JSON.parse(userState.user.setting);
settings.language = lang;
userDispatch({
type: 'login',
payload: {
...userState.user,
setting: JSON.stringify(settings),
},
});
settings = JSON.parse(userState.user.setting) || {};
} catch (e) {
// Ignore parse errors
settings = {};
}
}
settings.language = lang;
const nextUser = {
...userState.user,
setting: JSON.stringify(settings),
};
userDispatch({
type: 'login',
payload: nextUser,
});
localStorage.setItem('user', JSON.stringify(nextUser));
}
} catch (error) {
// Silently ignore errors - language was already changed locally
if (previousLang) {
i18n.changeLanguage(previousLang);
localStorage.setItem('i18nextLng', previousLang);
}
console.error('Failed to save language preference:', error);
}
}
+9 -1
View File
@@ -73,7 +73,7 @@ export const useModelPricingData = () => {
[statusState],
);
// USD/CNYTOKENS
// TOKENS
const siteDisplayType = useMemo(
() => statusState?.status?.quota_display_type || 'USD',
[statusState],
@@ -88,6 +88,13 @@ export const useModelPricingData = () => {
}
}, [siteDisplayType]);
useEffect(() => {
if (siteDisplayType === 'TOKENS') {
setShowWithRecharge(false);
setCurrency('USD');
}
}, [siteDisplayType]);
const filteredModels = useMemo(() => {
let result = models;
@@ -356,6 +363,7 @@ export const useModelPricingData = () => {
setCurrentPage,
currency,
setCurrency,
siteDisplayType,
showWithRecharge,
setShowWithRecharge,
tokenUnit,
+106 -44
View File
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui';
import {
@@ -29,6 +29,7 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
const { t } = useTranslation();
@@ -54,6 +55,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
// UI state
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
const [showKeys, setShowKeys] = useState({});
const [resolvedTokenKeys, setResolvedTokenKeys] = useState({});
const [loadingTokenKeys, setLoadingTokenKeys] = useState({});
const keyRequestsRef = useRef({});
// Form state
const [formApi, setFormApi] = useState(null);
@@ -87,6 +91,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
setTokenCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
setShowKeys({});
};
// Load tokens function
@@ -122,14 +127,86 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
}
};
const fetchTokenKey = async (tokenOrId, options = {}) => {
const { suppressError = false } = options;
const tokenId =
typeof tokenOrId === 'object' ? tokenOrId?.id : Number(tokenOrId);
if (!tokenId) {
const error = new Error(t('令牌不存在'));
if (!suppressError) {
showError(error.message);
}
throw error;
}
if (resolvedTokenKeys[tokenId]) {
return resolvedTokenKeys[tokenId];
}
if (keyRequestsRef.current[tokenId]) {
return keyRequestsRef.current[tokenId];
}
const request = (async () => {
setLoadingTokenKeys((prev) => ({ ...prev, [tokenId]: true }));
try {
const fullKey = await fetchTokenKeyById(tokenId);
setResolvedTokenKeys((prev) => ({ ...prev, [tokenId]: fullKey }));
return fullKey;
} catch (error) {
const normalizedError = new Error(
error?.message || t('获取令牌密钥失败'),
);
if (!suppressError) {
showError(normalizedError.message);
}
throw normalizedError;
} finally {
delete keyRequestsRef.current[tokenId];
setLoadingTokenKeys((prev) => {
const next = { ...prev };
delete next[tokenId];
return next;
});
}
})();
keyRequestsRef.current[tokenId] = request;
return request;
};
const toggleTokenVisibility = async (record) => {
const tokenId = record?.id;
if (!tokenId) {
return;
}
if (showKeys[tokenId]) {
setShowKeys((prev) => ({ ...prev, [tokenId]: false }));
return;
}
const fullKey = await fetchTokenKey(record);
if (fullKey) {
setShowKeys((prev) => ({ ...prev, [tokenId]: true }));
}
};
const copyTokenKey = async (record) => {
const fullKey = await fetchTokenKey(record);
await copyText(`sk-${fullKey}`);
};
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
const fullKey = await fetchTokenKey(record);
if (url && url.startsWith('ccswitch')) {
openCCSwitchModal(record.key);
openCCSwitchModal(fullKey);
return;
}
if (url && url.startsWith('fluent')) {
openFluentNotification(record.key);
openFluentNotification(fullKey);
return;
}
let status = localStorage.getItem('status');
@@ -145,7 +222,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
let cherryConfig = {
id: 'new-api',
baseUrl: serverAddress,
apiKey: 'sk-' + record.key,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(cherryConfig)),
@@ -155,7 +232,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
let aionuiConfig = {
platform: 'new-api',
baseUrl: serverAddress,
apiKey: 'sk-' + record.key,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(aionuiConfig)),
@@ -164,7 +241,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
} else {
let encodedServerAddress = encodeURIComponent(serverAddress);
url = url.replaceAll('{address}', encodedServerAddress);
url = url.replaceAll('{key}', 'sk-' + record.key);
url = url.replaceAll('{key}', `sk-${fullKey}`);
}
window.open(url, '_blank');
@@ -314,48 +391,28 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
};
// Batch copy tokens
const batchCopyTokens = (copyType) => {
const batchCopyTokens = async (copyType) => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
Modal.info({
title: t('复制令牌'),
icon: null,
content: t('请选择你的复制方式'),
footer: (
<div className='flex gap-2'>
<button
className='px-3 py-1 bg-gray-200 rounded'
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content +=
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
Modal.destroyAll();
}}
>
{t('名称+密钥')}
</button>
<button
className='px-3 py-1 bg-blue-500 text-white rounded'
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content += 'sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
Modal.destroyAll();
}}
>
{t('仅密钥')}
</button>
</div>
),
});
try {
const keys = await Promise.all(
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
);
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
const fullKey = keys[i];
if (copyType === 'name+key') {
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
} else {
content += `sk-${fullKey}\n`;
}
}
await copyText(content);
} catch (error) {
showError(error?.message || t('复制令牌失败'));
}
};
// Initialize data
@@ -392,6 +449,8 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
setCompactMode,
showKeys,
setShowKeys,
resolvedTokenKeys,
loadingTokenKeys,
// Form state
formApi,
@@ -403,6 +462,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
loadTokens,
refresh,
copyText,
fetchTokenKey,
toggleTokenVisibility,
copyTokenKey,
onOpenLink,
manageToken,
searchTokens,
+107 -44
View File
@@ -39,6 +39,7 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry';
export const useLogsData = () => {
const { t } = useTranslation();
@@ -78,6 +79,9 @@ export const useLogsData = () => {
const STORAGE_KEY = isAdminUser
? 'logs-table-columns-admin'
: 'logs-table-columns-user';
const BILLING_DISPLAY_MODE_STORAGE_KEY = isAdminUser
? 'logs-billing-display-mode-admin'
: 'logs-billing-display-mode-user';
// Statistics state
const [stat, setStat] = useState({
@@ -102,50 +106,6 @@ export const useLogsData = () => {
logType: '0',
};
// Column visibility state
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
// Compact mode
const [compactMode, setCompactMode] = useTableCompactMode('logs');
// User info modal state
const [showUserInfo, setShowUserInfoModal] = useState(false);
const [userInfoData, setUserInfoData] = useState(null);
// Channel affinity usage cache stats modal state (admin only)
const [
showChannelAffinityUsageCacheModal,
setShowChannelAffinityUsageCacheModal,
] = useState(false);
const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
useState(null);
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem(STORAGE_KEY);
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns);
const defaults = getDefaultColumnVisibility();
const merged = { ...defaults, ...parsed };
// For non-admin users, force-hide admin-only columns (does not touch admin settings)
if (!isAdminUser) {
merged[COLUMN_KEYS.CHANNEL] = false;
merged[COLUMN_KEYS.USERNAME] = false;
merged[COLUMN_KEYS.RETRY] = false;
}
setVisibleColumns(merged);
} catch (e) {
console.error('Failed to parse saved column preferences', e);
initDefaultColumns();
}
} else {
initDefaultColumns();
}
}, []);
// Get default column visibility based on user role
const getDefaultColumnVisibility = () => {
return {
@@ -166,6 +126,65 @@ export const useLogsData = () => {
};
};
const getInitialVisibleColumns = () => {
const defaults = getDefaultColumnVisibility();
const savedColumns = localStorage.getItem(STORAGE_KEY);
if (!savedColumns) {
return defaults;
}
try {
const parsed = JSON.parse(savedColumns);
const merged = { ...defaults, ...parsed };
if (!isAdminUser) {
merged[COLUMN_KEYS.CHANNEL] = false;
merged[COLUMN_KEYS.USERNAME] = false;
merged[COLUMN_KEYS.RETRY] = false;
}
return merged;
} catch (e) {
console.error('Failed to parse saved column preferences', e);
return defaults;
}
};
const getInitialBillingDisplayMode = () => {
const savedMode = localStorage.getItem(BILLING_DISPLAY_MODE_STORAGE_KEY);
if (savedMode === 'price' || savedMode === 'ratio') {
return savedMode;
}
return localStorage.getItem('quota_display_type') === 'TOKENS'
? 'ratio'
: 'price';
};
// Column visibility state
const [visibleColumns, setVisibleColumns] = useState(getInitialVisibleColumns);
const [showColumnSelector, setShowColumnSelector] = useState(false);
const [billingDisplayMode, setBillingDisplayMode] = useState(
getInitialBillingDisplayMode,
);
// Compact mode
const [compactMode, setCompactMode] = useTableCompactMode('logs');
// User info modal state
const [showUserInfo, setShowUserInfoModal] = useState(false);
const [userInfoData, setUserInfoData] = useState(null);
// Channel affinity usage cache stats modal state (admin only)
const [
showChannelAffinityUsageCacheModal,
setShowChannelAffinityUsageCacheModal,
] = useState(false);
const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
useState(null);
const [showParamOverrideModal, setShowParamOverrideModal] = useState(false);
const [paramOverrideTarget, setParamOverrideTarget] = useState(null);
// Initialize default column visibility
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
@@ -207,6 +226,10 @@ export const useLogsData = () => {
}
}, [visibleColumns]);
useEffect(() => {
localStorage.setItem(BILLING_DISPLAY_MODE_STORAGE_KEY, billingDisplayMode);
}, [BILLING_DISPLAY_MODE_STORAGE_KEY, billingDisplayMode]);
//
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
@@ -325,6 +348,20 @@ export const useLogsData = () => {
setShowChannelAffinityUsageCacheModal(true);
};
const openParamOverrideModal = (log, other) => {
const lines = Array.isArray(other?.po) ? other.po.filter(Boolean) : [];
if (lines.length === 0) {
return;
}
setParamOverrideTarget({
lines,
modelName: log?.model_name || '',
requestId: log?.request_id || '',
requestPath: other?.request_path || '',
});
setShowParamOverrideModal(true);
};
// Format logs data
const setLogsFormat = (logs) => {
const requestConversionDisplayValue = (conversionChain) => {
@@ -406,6 +443,7 @@ export const useLogsData = () => {
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
billingDisplayMode,
)
: renderLogContent(
other?.model_ratio,
@@ -420,6 +458,7 @@ export const useLogsData = () => {
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
billingDisplayMode,
),
});
if (logs[i]?.content) {
@@ -473,6 +512,7 @@ export const useLogsData = () => {
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
billingDisplayMode,
);
} else if (other?.claude) {
content = renderClaudeModelPrice(
@@ -495,6 +535,7 @@ export const useLogsData = () => {
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
billingDisplayMode,
);
} else {
content = renderModelPrice(
@@ -521,6 +562,7 @@ export const useLogsData = () => {
other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
billingDisplayMode,
);
}
expandDataLocal.push({
@@ -559,6 +601,21 @@ export const useLogsData = () => {
value: other.request_path,
});
}
if (Array.isArray(other?.po) && other.po.length > 0) {
expandDataLocal.push({
key: t('参数覆盖'),
value: (
<ParamOverrideEntry
count={other.po.length}
t={t}
onOpen={(event) => {
event.stopPropagation();
openParamOverrideModal(logs[i], other);
}}
/>
),
});
}
if (other?.billing_source === 'subscription') {
const planId = other?.subscription_plan_id;
const planTitle = other?.subscription_plan_title || '';
@@ -764,6 +821,8 @@ export const useLogsData = () => {
visibleColumns,
showColumnSelector,
setShowColumnSelector,
billingDisplayMode,
setBillingDisplayMode,
handleColumnVisibilityChange,
handleSelectAll,
initDefaultColumns,
@@ -784,6 +843,9 @@ export const useLogsData = () => {
setShowChannelAffinityUsageCacheModal,
channelAffinityUsageCacheTarget,
openChannelAffinityUsageCacheModal,
showParamOverrideModal,
setShowParamOverrideModal,
paramOverrideTarget,
// Functions
loadLogs,
@@ -795,6 +857,7 @@ export const useLogsData = () => {
setLogsFormat,
hasExpandableRows,
setLogType,
openParamOverrideModal,
// Translation
t,
+427 -339
View File
File diff suppressed because it is too large Load Diff
+499 -417
View File
File diff suppressed because it is too large Load Diff
+497 -415
View File
File diff suppressed because it is too large Load Diff
+500 -418
View File
File diff suppressed because it is too large Load Diff
+492 -412
View File
File diff suppressed because it is too large Load Diff
+82 -2
View File
@@ -13,7 +13,6 @@
"(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
"(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}",
"(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}": "(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}",
"(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}": "(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}",
"[最多请求次数]和[最多请求完成次数]的最大值为2147483647。": "[最多请求次数]和[最多请求完成次数]的最大值为2147483647。",
"[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。": "[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。",
"{\n \"default\": [200, 100],\n \"vip\": [0, 1000]\n}": "{\n \"default\": [200, 100],\n \"vip\": [0, 1000]\n}",
@@ -69,6 +68,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比",
"Claude设置": "Claude设置",
"Claude请求头覆盖": "Claude请求头覆盖",
"Claude请求头追加": "Claude请求头追加",
"Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"common.changeLanguage": "common.changeLanguage",
@@ -1414,6 +1415,7 @@
"格式化": "格式化",
"格式正确": "格式正确",
"格式示例:": "格式示例:",
"前:": "前:",
"格式错误": "格式错误",
"检查更新": "检查更新",
"检测到 FluentRead(流畅阅读)": "检测到 FluentRead(流畅阅读)",
@@ -1426,6 +1428,7 @@
"模型价格": "模型价格",
"模型价格 {{symbol}}{{price}}{{ratioType}} {{ratio}}": "模型价格 {{symbol}}{{price}}{{ratioType}} {{ratio}}",
"模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}": "模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}",
"按次:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}": "按次:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}",
"模型倍率": "模型倍率",
"模型倍率 {{modelRatio}}": "模型倍率 {{modelRatio}}",
"模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}}{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}}{{ratioType}} {{ratio}}",
@@ -2829,7 +2832,9 @@
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。",
"补全价格已锁定": "补全价格已锁定",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。",
"后:": "后:",
"这些价格都是可选项,不填也可以。": "这些价格都是可选项,不填也可以。",
"配置:": "配置:",
"请先开启并填写音频输入价格。": "请先开启并填写音频输入价格。",
"输入模型名称,例如 gpt-4.1": "输入模型名称,例如 gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。",
@@ -2858,6 +2863,81 @@
"音频补全价格": "音频补全价格",
"适合 MJ / 任务类等按次收费模型。": "适合 MJ / 任务类等按次收费模型。",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。",
"": "空"
"计费显示模式": "计费显示模式",
"价格模式(默认)": "价格模式(默认)",
"模型价格 {{symbol}}{{price}} / 次": "模型价格 {{symbol}}{{price}} / 次",
"按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
"模型价格:{{symbol}}{{price}} / 次": "模型价格:{{symbol}}{{price}} / 次",
"按次:{{symbol}}{{price}}": "按次:{{symbol}}{{price}}",
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)",
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "缓存读取价格:{{symbol}}{{price}} / 1M tokens",
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "缓存读取价格 {{symbol}}{{price}} / 1M tokens",
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "缓存创建价格:{{symbol}}{{price}} / 1M tokens",
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "缓存创建价格 {{symbol}}{{price}} / 1M tokens",
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "5m缓存创建价格:{{symbol}}{{price}} / 1M tokens",
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "5m缓存创建价格 {{symbol}}{{price}} / 1M tokens",
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "1h缓存创建价格:{{symbol}}{{price}} / 1M tokens",
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "1h缓存创建价格 {{symbol}}{{price}} / 1M tokens",
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "图片输入价格:{{symbol}}{{price}} / 1M tokens",
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "图片输入价格 {{symbol}}{{price}} / 1M tokens",
"输入价格 {{symbol}}{{price}} / 1M tokens": "输入价格 {{symbol}}{{price}} / 1M tokens",
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "音频输入价格:{{symbol}}{{price}} / 1M tokens",
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "音频补全价格:{{symbol}}{{price}} / 1M tokens",
"Web 搜索调用 {{webSearchCallCount}} 次": "Web 搜索调用 {{webSearchCallCount}} 次",
"文件搜索调用 {{fileSearchCallCount}} 次": "文件搜索调用 {{fileSearchCallCount}} 次",
"图片倍率 {{imageRatio}}": "图片倍率 {{imageRatio}}",
"音频倍率 {{audioRatio}}": "音频倍率 {{audioRatio}}",
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
"合计:{{total}}": "合计:{{total}}",
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}}{{cachePart}}{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}}{{cachePart}}{{ratioType}} {{ratio}}",
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}",
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}}{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}}{{ratioType}} {{ratio}}",
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"空": "空",
"{{ratioType}} {{ratio}}x": "{{ratioType}} {{ratio}}x",
"模型价格:{{symbol}}{{price}}": "模型价格:{{symbol}}{{price}}",
"模型价格 {{price}}": "模型价格 {{price}}",
"缓存读 {{price}} / 1M tokens": "缓存读 {{price}} / 1M tokens",
"5m缓存创建 {{price}} / 1M tokens": "5m缓存创建 {{price}} / 1M tokens",
"1h缓存创建 {{price}} / 1M tokens": "1h缓存创建 {{price}} / 1M tokens",
"缓存创建 {{price}} / 1M tokens": "缓存创建 {{price}} / 1M tokens",
"图片输入 {{price}} / 1M tokens": "图片输入 {{price}} / 1M tokens",
"输入 {{price}} / 1M tokens": "输入 {{price}} / 1M tokens",
"缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
"缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
"5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
"1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
"Key": "Key",
"Key 摘要": "Key 摘要",
"扣费": "扣费",
"渠道亲和性": "渠道亲和性",
"由订阅抵扣": "由订阅抵扣",
"规则": "规则",
"订阅抵扣": "订阅抵扣",
"违规扣费": "违规扣费",
"(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}": "(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}",
"图片输入价格:{{symbol}}{{total}} / 1M tokens": "图片输入价格:{{symbol}}{{total}} / 1M tokens",
"文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音频提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音频提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
"模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
"缓存读取价格:{{symbol}}{{total}} / 1M tokens": "缓存读取价格:{{symbol}}{{total}} / 1M tokens",
"补全 {{completion}} tokens * 输出倍率 {{completionRatio}}": "补全 {{completion}} tokens * 输出倍率 {{completionRatio}}",
"补全倍率 {{completionRatio}}": "补全倍率 {{completionRatio}}",
"输入价格:{{symbol}}{{price}} / 1M tokens": "输入价格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens"
}
}
+98 -2
View File
@@ -13,7 +13,6 @@
"(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(輸入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
"(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(輸入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音訊輸入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}",
"(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}": "(輸入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 快取 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}",
"(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}": "(輸入 {{nonImageInput}} tokens + 圖片輸入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}",
"[最多请求次数]和[最多请求完成次数]的最大值为2147483647。": "[最多請求次數]和[最多請求完成次數]的最大值為2147483647。",
"[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。": "[最多請求次數]必須大於等於0,[最多請求完成次數]必須大於等於1。",
"{\n \"default\": [200, 100],\n \"vip\": [0, 1000]\n}": "{\n \"default\": [200, 100],\n \"vip\": [0, 1000]\n}",
@@ -69,6 +68,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude思考相容 BudgetTokens = MaxTokens * BudgetTokens 百分比",
"Claude设置": "Claude設定",
"Claude请求头覆盖": "Claude請求頭覆蓋",
"Claude请求头追加": "Claude請求頭追加",
"Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude會在原有請求頭基礎上追加這些值,不會覆蓋已有同名請求頭;重複值會自動忽略。",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"common.changeLanguage": "common.changeLanguage",
@@ -1418,6 +1419,9 @@
"格式化": "格式化",
"格式正确": "格式正確",
"格式示例:": "格式示例:",
"前:": "前:",
"配置:": "配置:",
"后:": "後:",
"格式错误": "格式錯誤",
"检查更新": "檢查更新",
"检测到 FluentRead(流畅阅读)": "檢測到 FluentRead(流暢閱讀)",
@@ -1430,6 +1434,7 @@
"模型价格": "模型價格",
"模型价格 {{symbol}}{{price}}{{ratioType}} {{ratio}}": "模型價格 {{symbol}}{{price}}{{ratioType}} {{ratio}}",
"模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}": "模型價格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}",
"按次:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}": "按次:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}",
"模型倍率": "模型倍率",
"模型倍率 {{modelRatio}}": "模型倍率 {{modelRatio}}",
"模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}}{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},快取倍率 {{cacheRatio}},輸出倍率 {{completionRatio}}{{ratioType}} {{ratio}}",
@@ -2851,6 +2856,97 @@
"音频补全价格": "音訊補全價格",
"适合 MJ / 任务类等按次收费模型。": "適合 MJ / 任務類等按次收費模型。",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "該模型補全倍率由後端固定為 {{ratio}}。補全價格不能在這裡修改。",
"": "空"
"计费显示模式": "計費顯示模式",
"价格模式(默认)": "價格模式(預設)",
"模型价格 {{symbol}}{{price}} / 次": "模型價格 {{symbol}}{{price}} / 次",
"按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
"模型价格:{{symbol}}{{price}} / 次": "模型價格:{{symbol}}{{price}} / 次",
"按次:{{symbol}}{{price}}": "按次:{{symbol}}{{price}}",
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "實際結算金額:{{symbol}}{{total}}(已包含分組價格調整)",
"缓存读取价格:{{symbol}}{{price}} / 1M tokens": "快取讀取價格:{{symbol}}{{price}} / 1M tokens",
"缓存读取价格 {{symbol}}{{price}} / 1M tokens": "快取讀取價格 {{symbol}}{{price}} / 1M tokens",
"缓存创建价格:{{symbol}}{{price}} / 1M tokens": "快取建立價格:{{symbol}}{{price}} / 1M tokens",
"缓存创建价格 {{symbol}}{{price}} / 1M tokens": "快取建立價格 {{symbol}}{{price}} / 1M tokens",
"5m缓存创建价格:{{symbol}}{{price}} / 1M tokens": "5m快取建立價格:{{symbol}}{{price}} / 1M tokens",
"5m缓存创建价格 {{symbol}}{{price}} / 1M tokens": "5m快取建立價格 {{symbol}}{{price}} / 1M tokens",
"1h缓存创建价格:{{symbol}}{{price}} / 1M tokens": "1h快取建立價格:{{symbol}}{{price}} / 1M tokens",
"1h缓存创建价格 {{symbol}}{{price}} / 1M tokens": "1h快取建立價格 {{symbol}}{{price}} / 1M tokens",
"图片输入价格:{{symbol}}{{price}} / 1M tokens": "圖片輸入價格:{{symbol}}{{price}} / 1M tokens",
"图片输入价格 {{symbol}}{{price}} / 1M tokens": "圖片輸入價格 {{symbol}}{{price}} / 1M tokens",
"输入价格 {{symbol}}{{price}} / 1M tokens": "輸入價格 {{symbol}}{{price}} / 1M tokens",
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "音訊輸入價格:{{symbol}}{{price}} / 1M tokens",
"音频补全价格:{{symbol}}{{price}} / 1M tokens": "音訊補全價格:{{symbol}}{{price}} / 1M tokens",
"Web 搜索调用 {{webSearchCallCount}} 次": "Web 搜尋呼叫 {{webSearchCallCount}} 次",
"文件搜索调用 {{fileSearchCallCount}} 次": "檔案搜尋呼叫 {{fileSearchCallCount}} 次",
"图片倍率 {{imageRatio}}": "圖片倍率 {{imageRatio}}",
"音频倍率 {{audioRatio}}": "音訊倍率 {{audioRatio}}",
"普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "普通輸入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "快取輸入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 快取倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "圖片輸入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 圖片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音訊輸入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音訊倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 補全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "Web 搜尋:{{count}} / 1K * 單價 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
"文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "檔案搜尋:{{count}} / 1K * 單價 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
"图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}": "圖片生成:1 次 * 單價 {{price}} * {{ratioType}} {{ratio}} = {{amount}}",
"合计:{{total}}": "合計:{{total}}",
"模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}}{{cachePart}}{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},補全倍率 {{completionRatio}},音訊倍率 {{audioRatio}},音訊補全倍率 {{audioCompletionRatio}}{{cachePart}}{{ratioType}} {{ratio}}",
"文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "文字輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 補全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音訊輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音訊倍率 {{audioRatio}} * 音訊補全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}": "合計:文字部分 {{textTotal}} + 音訊部分 {{audioTotal}} = {{total}}",
"模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}}{{ratioType}} {{ratio}}": "模型倍率 {{modelRatio}},輸出倍率 {{completionRatio}},快取倍率 {{cacheRatio}}{{ratioType}} {{ratio}}",
"缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "快取讀取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 快取倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "快取建立:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 快取建立倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}": "5m快取建立:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m快取建立倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}",
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "1h快取建立:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h快取建立倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 輸出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"空": "空",
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端點映射僅用於模型廣場展示,不會影響模型真實呼叫。如需配置真實呼叫,請前往「管道管理」。",
"购买订阅获得模型额度/次数": "購買訂閱取得模型額度/次數",
"生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "正式環境 RSA 私鑰 Base64 (PKCS#8 DER)",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "沙盒環境 RSA 私鑰 Base64 (PKCS#8 DER)",
"生产环境 Waffo 公钥 Base64 (X.509 DER)": "正式環境 Waffo 公鑰 Base64 (X.509 DER)",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "沙盒環境 Waffo 公鑰 Base64 (X.509 DER)",
"支付方式类型": "付款方式類型",
"支付方式名称": "付款方式名稱",
"获取充值配置失败": "取得儲值設定失敗",
"获取充值配置异常": "儲值設定異常",
"{{ratioType}} {{ratio}}x": "{{ratioType}} {{ratio}}x",
"模型价格:{{symbol}}{{price}}": "模型價格:{{symbol}}{{price}}",
"模型价格 {{price}}": "模型價格 {{price}}",
"缓存读 {{price}} / 1M tokens": "快取讀 {{price}} / 1M tokens",
"5m缓存创建 {{price}} / 1M tokens": "5m快取建立 {{price}} / 1M tokens",
"1h缓存创建 {{price}} / 1M tokens": "1h快取建立 {{price}} / 1M tokens",
"缓存创建 {{price}} / 1M tokens": "快取建立 {{price}} / 1M tokens",
"图片输入 {{price}} / 1M tokens": "圖片輸入 {{price}} / 1M tokens",
"输入 {{price}} / 1M tokens": "輸入 {{price}} / 1M tokens",
"缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "快取 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
"缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "快取建立 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
"5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "5m快取建立 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
"1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}": "1h快取建立 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}",
"Key": "Key",
"Key 摘要": "Key 摘要",
"写": "寫",
"异步任务退款": "非同步任務退款",
"扣费": "扣費",
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "根據 Anthropic 協定,/v1/messages 的輸入 tokens 僅統計非快取輸入,不包含快取讀取與快取寫入 tokens。",
"渠道亲和性": "渠道親和性",
"由订阅抵扣": "由訂閱抵扣",
"缓存写": "快取寫",
"缓存读": "快取讀",
"规则": "規則",
"订阅抵扣": "訂閱抵扣",
"违规扣费": "違規扣費",
"退款": "退款",
"(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}": "(輸入 {{nonImageInput}} tokens + 圖片輸入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}",
"图片输入价格:{{symbol}}{{total}} / 1M tokens": "圖片輸入價格:{{symbol}}{{total}} / 1M tokens",
"文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音频提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字補全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音訊提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音訊補全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
"模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}": "模型價格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}",
"缓存读取价格:{{symbol}}{{total}} / 1M tokens": "快取讀取價格:{{symbol}}{{total}} / 1M tokens",
"补全 {{completion}} tokens * 输出倍率 {{completionRatio}}": "補全 {{completion}} tokens * 輸出倍率 {{completionRatio}}",
"补全倍率 {{completionRatio}}": "補全倍率 {{completionRatio}}",
"输入价格:{{symbol}}{{price}} / 1M tokens": "輸入價格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "輸出價格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "輸出價格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens"
}
}
@@ -39,6 +39,16 @@ const CLAUDE_HEADER = {
},
};
const CLAUDE_HEADER_APPEND_CONFIG = {
'claude-3-7-sonnet-20250219-thinking': {
'anthropic-beta': ['token-efficient-tools-2025-02-19'],
},
};
const CLAUDE_HEADER_APPEND_BEFORE = `anthropic-beta: output-128k-2025-02-19`;
const CLAUDE_HEADER_APPEND_AFTER = `anthropic-beta: output-128k-2025-02-19,token-efficient-tools-2025-02-19`;
const CLAUDE_DEFAULT_MAX_TOKENS = {
default: 8192,
'claude-3-haiku-20240307': 4096,
@@ -114,7 +124,7 @@ export default function SettingClaudeModel(props) {
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.TextArea
label={t('Claude请求头覆盖')}
label={t('Claude请求头追加')}
field={'claude.model_headers_settings'}
placeholder={
t('为一个 JSON 文本,例如:') +
@@ -122,7 +132,20 @@ export default function SettingClaudeModel(props) {
JSON.stringify(CLAUDE_HEADER, null, 2)
}
extraText={
t('示例') + '\n' + JSON.stringify(CLAUDE_HEADER, null, 2)
<div>
<div>
{t(
'Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。',
)}
</div>
<div className='mt-2 whitespace-pre-wrap font-mono text-xs'>
{`${t('前:')}\n${CLAUDE_HEADER_APPEND_BEFORE}\n\n${t('配置:')}\n${JSON.stringify(
CLAUDE_HEADER_APPEND_CONFIG,
null,
2,
)}\n\n${t('后:')}\n${CLAUDE_HEADER_APPEND_AFTER}`}
</div>
</div>
}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
@@ -31,8 +31,7 @@ import { useTranslation } from 'react-i18next';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const GEMINI_SETTING_EXAMPLE = {
default: 'OFF',
HARM_CATEGORY_CIVIC_INTEGRITY: 'BLOCK_NONE',
default: 'OFF'
};
const GEMINI_VERSION_EXAMPLE = {
@@ -0,0 +1,608 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
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,
Form,
Row,
Col,
Typography,
Spin,
Table,
Modal,
Input,
Space,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export default function SettingsPaymentGatewayWaffo(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
WaffoEnabled: false,
WaffoApiKey: '',
WaffoPrivateKey: '',
WaffoPublicCert: '',
WaffoSandboxPublicCert: '',
WaffoSandboxApiKey: '',
WaffoSandboxPrivateKey: '',
WaffoSandbox: false,
WaffoMerchantId: '',
WaffoCurrency: 'USD',
WaffoUnitPrice: 1.0,
WaffoMinTopUp: 1,
WaffoNotifyUrl: '',
WaffoReturnUrl: '',
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
const iconFileInputRef = useRef(null);
const handleIconFileChange = (e) => {
const file = e.target.files[0];
if (!file) return;
const MAX_ICON_SIZE = 100 * 1024; // 100 KB
if (file.size > MAX_ICON_SIZE) {
showError(t('图标文件不能超过 100KB,请压缩后重新上传'));
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (event) => {
setPayMethodForm((prev) => ({ ...prev, icon: event.target.result }));
};
reader.readAsDataURL(file);
e.target.value = '';
};
//
const [waffoPayMethods, setWaffoPayMethods] = useState([]);
//
const [payMethodModalVisible, setPayMethodModalVisible] = useState(false);
// -1
const [editingPayMethodIndex, setEditingPayMethodIndex] = useState(-1);
//
const [payMethodForm, setPayMethodForm] = useState({
name: '',
icon: '',
payMethodType: '',
payMethodName: '',
});
useEffect(() => {
if (props.options && formApiRef.current) {
const currentInputs = {
WaffoEnabled: props.options.WaffoEnabled === 'true' || props.options.WaffoEnabled === true,
WaffoApiKey: props.options.WaffoApiKey || '',
WaffoPrivateKey: props.options.WaffoPrivateKey || '',
WaffoPublicCert: props.options.WaffoPublicCert || '',
WaffoSandboxPublicCert: props.options.WaffoSandboxPublicCert || '',
WaffoSandboxApiKey: props.options.WaffoSandboxApiKey || '',
WaffoSandboxPrivateKey: props.options.WaffoSandboxPrivateKey || '',
WaffoSandbox: props.options.WaffoSandbox === 'true',
WaffoMerchantId: props.options.WaffoMerchantId || '',
WaffoCurrency: props.options.WaffoCurrency || 'USD',
WaffoUnitPrice: parseFloat(props.options.WaffoUnitPrice) || 1.0,
WaffoMinTopUp: parseInt(props.options.WaffoMinTopUp) || 1,
WaffoNotifyUrl: props.options.WaffoNotifyUrl || '',
WaffoReturnUrl: props.options.WaffoReturnUrl || '',
};
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
//
try {
const rawPayMethods = props.options.WaffoPayMethods;
if (rawPayMethods) {
const parsed = JSON.parse(rawPayMethods);
if (Array.isArray(parsed)) {
setWaffoPayMethods(parsed);
}
}
} catch {
setWaffoPayMethods([]);
}
}
}, [props.options]);
const handleFormChange = (values) => {
setInputs(values);
};
const submitWaffoSetting = async () => {
setLoading(true);
try {
const options = [];
options.push({
key: 'WaffoEnabled',
value: inputs.WaffoEnabled ? 'true' : 'false',
});
if (inputs.WaffoApiKey && inputs.WaffoApiKey !== '') {
options.push({ key: 'WaffoApiKey', value: inputs.WaffoApiKey });
}
if (inputs.WaffoPrivateKey && inputs.WaffoPrivateKey !== '') {
options.push({ key: 'WaffoPrivateKey', value: inputs.WaffoPrivateKey });
}
options.push({ key: 'WaffoPublicCert', value: inputs.WaffoPublicCert || '' });
options.push({ key: 'WaffoSandboxPublicCert', value: inputs.WaffoSandboxPublicCert || '' });
if (inputs.WaffoSandboxApiKey && inputs.WaffoSandboxApiKey !== '') {
options.push({ key: 'WaffoSandboxApiKey', value: inputs.WaffoSandboxApiKey });
}
if (inputs.WaffoSandboxPrivateKey && inputs.WaffoSandboxPrivateKey !== '') {
options.push({ key: 'WaffoSandboxPrivateKey', value: inputs.WaffoSandboxPrivateKey });
}
options.push({
key: 'WaffoSandbox',
value: inputs.WaffoSandbox ? 'true' : 'false',
});
options.push({ key: 'WaffoMerchantId', value: inputs.WaffoMerchantId || '' });
options.push({ key: 'WaffoCurrency', value: inputs.WaffoCurrency || '' });
options.push({
key: 'WaffoUnitPrice',
value: String(inputs.WaffoUnitPrice || 1.0),
});
options.push({
key: 'WaffoMinTopUp',
value: String(inputs.WaffoMinTopUp || 1),
});
options.push({ key: 'WaffoNotifyUrl', value: inputs.WaffoNotifyUrl || '' });
options.push({ key: 'WaffoReturnUrl', value: inputs.WaffoReturnUrl || '' });
//
options.push({
key: 'WaffoPayMethods',
value: JSON.stringify(waffoPayMethods),
});
//
const requestQueue = options.map((opt) =>
API.put('/api/option/', {
key: opt.key,
value: opt.value,
}),
);
const results = await Promise.all(requestQueue);
//
const errorResults = results.filter((res) => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach((res) => {
showError(res.data.message);
});
} else {
showSuccess(t('更新成功'));
//
setOriginInputs({ ...inputs });
props.refresh?.();
}
} catch (error) {
showError(t('更新失败'));
}
setLoading(false);
};
//
const openAddPayMethodModal = () => {
setEditingPayMethodIndex(-1);
setPayMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' });
setPayMethodModalVisible(true);
};
//
const openEditPayMethodModal = (record, index) => {
setEditingPayMethodIndex(index);
setPayMethodForm({
name: record.name || '',
icon: record.icon || '',
payMethodType: record.payMethodType || '',
payMethodName: record.payMethodName || '',
});
setPayMethodModalVisible(true);
};
//
const handlePayMethodModalOk = () => {
if (!payMethodForm.name || payMethodForm.name.trim() === '') {
showError(t('支付方式名称不能为空'));
return;
}
const newMethod = {
name: payMethodForm.name.trim(),
icon: payMethodForm.icon.trim(),
payMethodType: payMethodForm.payMethodType.trim(),
payMethodName: payMethodForm.payMethodName.trim(),
};
if (editingPayMethodIndex === -1) {
//
setWaffoPayMethods([...waffoPayMethods, newMethod]);
} else {
//
const updated = [...waffoPayMethods];
updated[editingPayMethodIndex] = newMethod;
setWaffoPayMethods(updated);
}
setPayMethodModalVisible(false);
};
//
const handleDeletePayMethod = (index) => {
const updated = waffoPayMethods.filter((_, i) => i !== index);
setWaffoPayMethods(updated);
};
//
const payMethodColumns = [
{
title: t('显示名称'),
dataIndex: 'name',
},
{
title: t('图标'),
dataIndex: 'icon',
render: (text) =>
text ? (
<img
src={text}
alt='icon'
style={{ width: 24, height: 24, objectFit: 'contain' }}
/>
) : (
<Text type='tertiary'></Text>
),
},
{
title: t('支付方式类型'),
dataIndex: 'payMethodType',
render: (text) => text || <Text type='tertiary'></Text>,
},
{
title: t('支付方式名称'),
dataIndex: 'payMethodName',
render: (text) => text || <Text type='tertiary'></Text>,
},
{
title: t('操作'),
key: 'action',
render: (_, record, index) => (
<Space>
<Button
size='small'
onClick={() => openEditPayMethodModal(record, index)}
>
{t('编辑')}
</Button>
<Button
size='small'
type='danger'
onClick={() => handleDeletePayMethod(index)}
>
{t('删除')}
</Button>
</Space>
),
},
];
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Waffo 设置')}>
<Text>
{t('Waffo 是一个支付聚合平台,支持多种支付方式。')}
<a href='https://waffo.com' target='_blank' rel='noreferrer'>
Waffo Official Site
</a>
<br />
</Text>
<Banner
type='info'
description={t(
'请在 Waffo 后台获取 API 密钥、商户 ID 以及 RSA 密钥对,并配置回调地址。',
)}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoEnabled'
label={t('启用 Waffo')}
size='default'
checkedText=''
uncheckedText=''
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoSandbox'
label={t('沙盒模式')}
size='default'
checkedText=''
uncheckedText=''
extraText={t('启用后将使用 Waffo 沙盒环境')}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoApiKey'
label={t('API 密钥 (生产)')}
placeholder={t('生产环境 Waffo API 密钥')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoSandboxApiKey'
label={t('API 密钥 (沙盒)')}
placeholder={t('沙盒环境 Waffo API 密钥')}
type='password'
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoMerchantId'
label={t('商户 ID')}
placeholder={t('Waffo 商户 ID')}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPrivateKey'
label={t('RSA 私钥 (生产)')}
placeholder={t('生产环境 RSA 私钥 Base64 (PKCS#8 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoSandboxPrivateKey'
label={t('RSA 私钥 (沙盒)')}
placeholder={t('沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPublicCert'
label={t('Waffo 公钥 (生产)')}
placeholder={t('生产环境 Waffo 公钥 Base64 (X.509 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoSandboxPublicCert'
label={t('Waffo 公钥 (沙盒)')}
placeholder={t('沙盒环境 Waffo 公钥 Base64 (X.509 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoCurrency'
label={t('货币')}
disabled
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoUnitPrice'
label={t('单价 (USD)')}
placeholder='1.0'
min={0}
step={0.1}
extraText={t('每个充值单位对应的 USD 金额,默认 1.0')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoMinTopUp'
label={t('最低充值数量')}
placeholder='1'
min={1}
step={1}
extraText={t('Waffo 充值的最低数量,默认 1')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoNotifyUrl'
label={t('回调通知地址')}
placeholder={t('例如 https://example.com/api/waffo/webhook')}
extraText={t('留空则自动使用 服务器地址 + /api/waffo/webhook')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoReturnUrl'
label={t('支付返回地址')}
placeholder={t('例如 https://example.com/console/topup')}
extraText={t('支付完成后用户跳转的页面,留空则自动使用 服务器地址 + /console/topup')}
/>
</Col>
</Row>
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
{t('更新 Waffo 设置')}
</Button>
</Form.Section>
</Form>
{/* 支付方式配置区块(独立于 Form,使用独立状态管理) */}
<div style={{ marginTop: 24 }}>
<Typography.Title heading={6} style={{ marginBottom: 8 }}>{t('支付方式')}</Typography.Title>
<Text type='secondary'>
{t('配置 Waffo 充值时可用的支付方式,保存后在充值页面展示给用户。')}
</Text>
<div style={{ marginTop: 12, marginBottom: 12 }}>
<Button onClick={openAddPayMethodModal}>
{t('新增支付方式')}
</Button>
</div>
<Table
columns={payMethodColumns}
dataSource={waffoPayMethods}
rowKey={(record, index) => index}
pagination={false}
size='small'
empty={<Text type='tertiary'>{t('暂无支付方式,点击上方按钮新增')}</Text>}
/>
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
{t('更新 Waffo 设置')}
</Button>
</div>
{/* 新增/编辑支付方式弹窗 */}
<Modal
title={editingPayMethodIndex === -1 ? t('新增支付方式') : t('编辑支付方式')}
visible={payMethodModalVisible}
onOk={handlePayMethodModalOk}
onCancel={() => setPayMethodModalVisible(false)}
okText={t('确定')}
cancelText={t('取消')}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('显示名称')}</Text>
<span style={{ color: 'var(--semi-color-danger)', marginLeft: 4 }}>*</span>
</div>
<Input
value={payMethodForm.name}
onChange={(val) => setPayMethodForm({ ...payMethodForm, name: val })}
placeholder={t('例如:Credit Card')}
/>
<Text type='tertiary' size='small'>{t('用户在充值页面看到的支付方式名称,例如:Credit Card')}</Text>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('图标')}</Text>
</div>
<Space align='center'>
{payMethodForm.icon && (
<img
src={payMethodForm.icon}
alt='preview'
style={{
width: 32,
height: 32,
objectFit: 'contain',
border: '1px solid var(--semi-color-border)',
borderRadius: 4,
}}
/>
)}
<input
type='file'
accept='image/*'
ref={iconFileInputRef}
style={{ display: 'none' }}
onChange={handleIconFileChange}
/>
<Button
size='small'
onClick={() => iconFileInputRef.current?.click()}
>
{payMethodForm.icon ? t('重新上传') : t('上传图片')}
</Button>
{payMethodForm.icon && (
<Button
size='small'
type='danger'
onClick={() =>
setPayMethodForm((prev) => ({ ...prev, icon: '' }))
}
>
{t('清除')}
</Button>
)}
</Space>
<div>
<Text type='tertiary' size='small'>{t('上传 PNG/JPG/SVG 图片,建议尺寸 ≤ 128×128px')}</Text>
</div>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('Pay Method Type')}</Text>
</div>
<Input
value={payMethodForm.payMethodType}
onChange={(val) => setPayMethodForm({ ...payMethodForm, payMethodType: val })}
placeholder='CREDITCARD,DEBITCARD'
maxLength={64}
/>
<Text type='tertiary' size='small'>{t('Waffo API 参数,可空,例如:CREDITCARD,DEBITCARD(最多64位)')}</Text>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('Pay Method Name')}</Text>
</div>
<Input
value={payMethodForm.payMethodName}
onChange={(val) => setPayMethodForm({ ...payMethodForm, payMethodName: val })}
placeholder={t('可空')}
maxLength={64}
/>
<Text type='tertiary' size='small'>{t('Waffo API 参数,可空(最多64位)')}</Text>
</div>
</div>
</Modal>
</Spin>
);
}
@@ -59,6 +59,11 @@ const formatNumber = (value) => {
return parseFloat(num.toFixed(12)).toString();
};
const toNormalizedNumber = (value) => {
const formatted = formatNumber(value);
return formatted === '' ? null : Number(formatted);
};
const parseOptionJSON = (rawValue) => {
if (!rawValue || rawValue.trim() === '') {
return {};
@@ -123,7 +128,11 @@ const buildModelState = (name, sourceMaps) => {
lockedCompletionRatio: completionRatioMeta.ratio,
completionPrice:
inputPriceNumber !== null &&
hasValue(completionRatioMeta.locked ? completionRatioMeta.ratio : completionRatio)
hasValue(
completionRatioMeta.locked
? completionRatioMeta.ratio
: completionRatio,
)
? formatNumber(
inputPriceNumber *
Number(
@@ -192,7 +201,9 @@ export const getModelWarnings = (model, t) => {
].some(hasValue);
if (model.hasConflict) {
warnings.push(t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。'));
warnings.push(
t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。'),
);
}
if (
@@ -207,11 +218,17 @@ export const getModelWarnings = (model, t) => {
].some(hasValue)
) {
warnings.push(
t('当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。'),
t(
'当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。',
),
);
}
if (model.billingMode === 'per-token' && hasDerivedPricing && !hasValue(model.inputPrice)) {
if (
model.billingMode === 'per-token' &&
hasDerivedPricing &&
!hasValue(model.inputPrice)
) {
warnings.push(t('按量计费下需要先填写输入价格,才能保存其它价格项。'));
}
@@ -249,7 +266,8 @@ export const buildSummaryText = (model, t) => {
};
export const buildOptionalFieldToggles = (model) => ({
completionPrice: model.completionRatioLocked || hasValue(model.completionPrice),
completionPrice:
model.completionRatioLocked || hasValue(model.completionPrice),
cachePrice: hasValue(model.cachePrice),
createCachePrice: hasValue(model.createCachePrice),
imagePrice: hasValue(model.imagePrice),
@@ -271,7 +289,7 @@ const serializeModel = (model, t) => {
if (model.billingMode === 'per-request') {
if (hasValue(model.fixedPrice)) {
result.ModelPrice = Number(model.fixedPrice);
result.ModelPrice = toNormalizedNumber(model.fixedPrice);
}
return result;
}
@@ -296,57 +314,68 @@ const serializeModel = (model, t) => {
if (inputPrice === null) {
if (hasDependentPrice) {
throw new Error(
t('模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率', {
name: model.name,
}),
t(
'模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率',
{
name: model.name,
},
),
);
}
if (hasValue(model.rawRatios.modelRatio)) {
result.ModelRatio = Number(model.rawRatios.modelRatio);
result.ModelRatio = toNormalizedNumber(model.rawRatios.modelRatio);
}
if (hasValue(model.rawRatios.completionRatio)) {
result.CompletionRatio = Number(model.rawRatios.completionRatio);
result.CompletionRatio = toNormalizedNumber(
model.rawRatios.completionRatio,
);
}
if (hasValue(model.rawRatios.cacheRatio)) {
result.CacheRatio = Number(model.rawRatios.cacheRatio);
result.CacheRatio = toNormalizedNumber(model.rawRatios.cacheRatio);
}
if (hasValue(model.rawRatios.createCacheRatio)) {
result.CreateCacheRatio = Number(model.rawRatios.createCacheRatio);
result.CreateCacheRatio = toNormalizedNumber(
model.rawRatios.createCacheRatio,
);
}
if (hasValue(model.rawRatios.imageRatio)) {
result.ImageRatio = Number(model.rawRatios.imageRatio);
result.ImageRatio = toNormalizedNumber(model.rawRatios.imageRatio);
}
if (hasValue(model.rawRatios.audioRatio)) {
result.AudioRatio = Number(model.rawRatios.audioRatio);
result.AudioRatio = toNormalizedNumber(model.rawRatios.audioRatio);
}
if (hasValue(model.rawRatios.audioCompletionRatio)) {
result.AudioCompletionRatio = Number(model.rawRatios.audioCompletionRatio);
result.AudioCompletionRatio = toNormalizedNumber(
model.rawRatios.audioCompletionRatio,
);
}
return result;
}
result.ModelRatio = inputPrice / 2;
result.ModelRatio = toNormalizedNumber(inputPrice / 2);
if (!model.completionRatioLocked && completionPrice !== null) {
result.CompletionRatio = completionPrice / inputPrice;
result.CompletionRatio = toNormalizedNumber(completionPrice / inputPrice);
} else if (
model.completionRatioLocked &&
hasValue(model.rawRatios.completionRatio)
) {
result.CompletionRatio = Number(model.rawRatios.completionRatio);
result.CompletionRatio = toNormalizedNumber(
model.rawRatios.completionRatio,
);
}
if (cachePrice !== null) {
result.CacheRatio = cachePrice / inputPrice;
result.CacheRatio = toNormalizedNumber(cachePrice / inputPrice);
}
if (createCachePrice !== null) {
result.CreateCacheRatio = createCachePrice / inputPrice;
result.CreateCacheRatio = toNormalizedNumber(createCachePrice / inputPrice);
}
if (imagePrice !== null) {
result.ImageRatio = imagePrice / inputPrice;
result.ImageRatio = toNormalizedNumber(imagePrice / inputPrice);
}
if (audioInputPrice !== null) {
result.AudioRatio = audioInputPrice / inputPrice;
result.AudioRatio = toNormalizedNumber(audioInputPrice / inputPrice);
}
if (audioOutputPrice !== null) {
if (audioInputPrice === null || audioInputPrice === 0) {
@@ -356,7 +385,9 @@ const serializeModel = (model, t) => {
}),
);
}
result.AudioCompletionRatio = audioOutputPrice / audioInputPrice;
result.AudioCompletionRatio = toNormalizedNumber(
audioOutputPrice / audioInputPrice,
);
}
return result;
@@ -455,7 +486,8 @@ export const buildPreviewRows = (model, t) => {
{
key: 'CacheRatio',
label: 'CacheRatio',
value: cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'),
value:
cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'),
},
{
key: 'CreateCacheRatio',
@@ -468,7 +500,8 @@ export const buildPreviewRows = (model, t) => {
{
key: 'ImageRatio',
label: 'ImageRatio',
value: imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'),
value:
imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'),
},
{
key: 'AudioRatio',
@@ -482,7 +515,9 @@ export const buildPreviewRows = (model, t) => {
key: 'AudioCompletionRatio',
label: 'AudioCompletionRatio',
value:
audioOutputPrice !== null && audioInputPrice !== null && audioInputPrice !== 0
audioOutputPrice !== null &&
audioInputPrice !== null &&
audioInputPrice !== 0
? formatNumber(audioOutputPrice / audioInputPrice)
: t('空'),
},
@@ -585,7 +620,8 @@ export function useModelPricingEditorState({
}, [currentPage, filteredModels]);
const selectedModel = useMemo(
() => visibleModels.find((model) => model.name === selectedModelName) || null,
() =>
visibleModels.find((model) => model.name === selectedModelName) || null,
[selectedModelName, visibleModels],
);
@@ -605,7 +641,9 @@ export function useModelPricingEditorState({
useEffect(() => {
setSelectedModelNames((previous) =>
previous.filter((name) => visibleModels.some((model) => model.name === name)),
previous.filter((name) =>
visibleModels.some((model) => model.name === name),
),
);
}, [visibleModels]);
@@ -779,7 +817,9 @@ export function useModelPricingEditorState({
delete next[name];
return next;
});
setSelectedModelNames((previous) => previous.filter((item) => item !== name));
setSelectedModelNames((previous) =>
previous.filter((item) => item !== name),
);
if (selectedModelName === name) {
setSelectedModelName(nextModels[0]?.name || '');
}
@@ -823,7 +863,8 @@ export function useModelPricingEditorState({
hasValue(nextModel.lockedCompletionRatio)
) {
nextModel.completionPrice = formatNumber(
Number(nextModel.inputPrice) * Number(nextModel.lockedCompletionRatio),
Number(nextModel.inputPrice) *
Number(nextModel.lockedCompletionRatio),
);
}