Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ed2ea6ec1 | |||
| 620e066b39 | |||
| 0246b20bf1 | |||
| 69551ab2de | |||
| 8aa8b81e03 | |||
| bc80477b1a | |||
| 5db25f47f1 | |||
| a4fd2246ba | |||
| 4e5e7b5828 | |||
| 95738594b4 | |||
| efab41c476 | |||
| c77c82421e | |||
| e4144d60f8 | |||
| 63f4595ef8 | |||
| 5e856f0263 | |||
| b9f1d01e00 | |||
| 5d620b9640 | |||
| 264bc963e0 | |||
| 9fbb782230 | |||
| da8a52f50a | |||
| 9fdb0bc248 | |||
| 24ec27f844 | |||
| 5e9cc681f5 | |||
| 7e68e1b36a | |||
| 45a59d32fb | |||
| c1c07d063d | |||
| 7fc39363d7 | |||
| 7b62694f60 | |||
| 3b5d1daf39 | |||
| d087cc5025 | |||
| d67f446b66 | |||
| ac72f90fc5 | |||
| 3f662e4bc0 | |||
| 287af7ebee | |||
| aa89ea2db5 | |||
| 8d7d880db5 | |||
| 50ec2bac6b | |||
| c0a0285f74 | |||
| fb76abb329 | |||
| 9905599d27 | |||
| 329416d67b | |||
| ffb06d084b | |||
| 2e20ede2a0 | |||
| 9cfaa68e5a | |||
| 57d525869a | |||
| 3defef3588 | |||
| 172f92aa72 | |||
| 12aacf27b6 | |||
| 728607b8f5 | |||
| 5372d9ba55 | |||
| cd1d43ae47 | |||
| 5bd67d0a4e | |||
| 42500b3317 | |||
| 5df8b34f78 | |||
| c6ca4c3bda | |||
| d2332685db | |||
| 1b17986283 | |||
| a4629f2630 | |||
| f53f326931 | |||
| 2a87c043d1 | |||
| 816fdff703 | |||
| bd6b728622 | |||
| 6f818574ab | |||
| 092807b72b | |||
| 3844ecca21 | |||
| 4f7c4d6441 | |||
| 638cb0a091 | |||
| f6f5a6f875 | |||
| 4798165272 | |||
| c79c1f95fd | |||
| 88b7322483 |
@@ -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: ''
|
||||
**预期结果**
|
||||
|
||||
**相关截图**
|
||||
如果没有的话,请删除此节。
|
||||
@@ -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.
|
||||
@@ -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 查询或提问。
|
||||
|
||||
@@ -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 可能会被无视或直接关闭
|
||||
|
||||
**功能描述**
|
||||
|
||||
|
||||
@@ -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
|
||||
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
- '!nightly*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaychannel "github.com/QuantumNous/new-api/relay/channel"
|
||||
"github.com/QuantumNous/new-api/relay/channel/gemini"
|
||||
"github.com/QuantumNous/new-api/relay/channel/ollama"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -183,6 +184,9 @@ func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, e
|
||||
|
||||
headerOverride := channel.GetHeaderOverride()
|
||||
for k, v := range headerOverride {
|
||||
if relaychannel.IsHeaderPassthroughRuleKey(k) {
|
||||
continue
|
||||
}
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid header override for key %s", k)
|
||||
|
||||
@@ -730,14 +730,6 @@ func DetectChannelUpstreamModelUpdates(c *gin.Context) {
|
||||
}
|
||||
|
||||
settings := channel.GetOtherSettings()
|
||||
if !settings.UpstreamModelUpdateCheckEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该渠道未开启上游模型更新检测",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
|
||||
+58
-3
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -17,10 +16,56 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var completionRatioMetaOptionKeys = []string{
|
||||
"ModelPrice",
|
||||
"ModelRatio",
|
||||
"CompletionRatio",
|
||||
"CacheRatio",
|
||||
"CreateCacheRatio",
|
||||
"ImageRatio",
|
||||
"AudioRatio",
|
||||
"AudioCompletionRatio",
|
||||
}
|
||||
|
||||
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := common.UnmarshalJsonStr(raw, &parsed); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for modelName := range parsed {
|
||||
modelNames[modelName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func buildCompletionRatioMetaValue(optionValues map[string]string) string {
|
||||
modelNames := make(map[string]struct{})
|
||||
for _, key := range completionRatioMetaOptionKeys {
|
||||
collectModelNamesFromOptionValue(optionValues[key], modelNames)
|
||||
}
|
||||
|
||||
meta := make(map[string]ratio_setting.CompletionRatioInfo, len(modelNames))
|
||||
for modelName := range modelNames {
|
||||
meta[modelName] = ratio_setting.GetCompletionRatioInfo(modelName)
|
||||
}
|
||||
|
||||
jsonBytes, err := common.Marshal(meta)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func GetOptions(c *gin.Context) {
|
||||
var options []*model.Option
|
||||
optionValues := make(map[string]string)
|
||||
common.OptionMapRWMutex.Lock()
|
||||
for k, v := range common.OptionMap {
|
||||
value := common.Interface2String(v)
|
||||
if strings.HasSuffix(k, "Token") ||
|
||||
strings.HasSuffix(k, "Secret") ||
|
||||
strings.HasSuffix(k, "Key") ||
|
||||
@@ -30,10 +75,20 @@ func GetOptions(c *gin.Context) {
|
||||
}
|
||||
options = append(options, &model.Option{
|
||||
Key: k,
|
||||
Value: common.Interface2String(v),
|
||||
Value: value,
|
||||
})
|
||||
for _, optionKey := range completionRatioMetaOptionKeys {
|
||||
if optionKey == k {
|
||||
optionValues[k] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
options = append(options, &model.Option{
|
||||
Key: "CompletionRatioMeta",
|
||||
Value: buildCompletionRatioMetaValue(optionValues),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -49,7 +104,7 @@ type OptionUpdateRequest struct {
|
||||
|
||||
func UpdateOption(c *gin.Context) {
|
||||
var option OptionUpdateRequest
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&option)
|
||||
err := common.DecodeJson(c.Request.Body, &option)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
|
||||
@@ -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 验证成功",
|
||||
|
||||
@@ -341,6 +341,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
||||
if code < 100 || code > 599 {
|
||||
return true
|
||||
}
|
||||
if operation_setting.IsAlwaysSkipRetryCode(openaiErr.GetErrorCode()) {
|
||||
return false
|
||||
}
|
||||
return operation_setting.ShouldRetryByStatusCode(code)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ func VideoProxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
task, exists, err := model.GetByOnlyTaskId(taskID)
|
||||
userID := c.GetInt("id")
|
||||
task, exists, err := model.GetByTaskId(userID, taskID)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error()))
|
||||
videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to query task")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,12 +27,12 @@ type ChannelOtherSettings struct {
|
||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
|
||||
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
|
||||
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
|
||||
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
|
||||
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
|
||||
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
AllowIncludeObfuscation bool `json:"allow_include_obfuscation, omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
|
||||
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
|
||||
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
|
||||
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
|
||||
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
|
||||
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
|
||||
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
|
||||
UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新
|
||||
UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新
|
||||
|
||||
@@ -218,6 +218,11 @@ type ClaudeRequest struct {
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
}
|
||||
|
||||
// OutputConfigForEffort just for extract effort
|
||||
type OutputConfigForEffort struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createClaudeFileSource(data string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
@@ -409,6 +414,15 @@ func (c *ClaudeRequest) GetTools() []any {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ClaudeRequest) GetEfforts() string {
|
||||
var OutputConfig OutputConfigForEffort
|
||||
if err := json.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
|
||||
effort := OutputConfig.Effort
|
||||
return effort
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ProcessTools 处理工具列表,支持类型断言
|
||||
func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
|
||||
var normalTools []*Tool
|
||||
|
||||
@@ -56,10 +56,10 @@ type GeneralOpenAIRequest struct {
|
||||
Tools []ToolCallRequest `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
FunctionCall json.RawMessage `json:"function_call,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
User json.RawMessage `json:"user,omitempty"`
|
||||
// ServiceTier specifies upstream service level and may affect billing.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
ServiceTier json.RawMessage `json:"service_tier,omitempty"`
|
||||
LogProbs *bool `json:"logprobs,omitempty"`
|
||||
TopLogProbs *int `json:"top_logprobs,omitempty"`
|
||||
Dimensions *int `json:"dimensions,omitempty"`
|
||||
@@ -67,7 +67,7 @@ type GeneralOpenAIRequest struct {
|
||||
Audio json.RawMessage `json:"audio,omitempty"`
|
||||
// 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户
|
||||
// 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤,可通过 allow_safety_identifier 开启
|
||||
SafetyIdentifier string `json:"safety_identifier,omitempty"`
|
||||
SafetyIdentifier json.RawMessage `json:"safety_identifier,omitempty"`
|
||||
// Whether or not to store the output of this chat completion request for use in our model distillation or evals products.
|
||||
// 是否存储此次请求数据供 OpenAI 用于评估和优化产品
|
||||
// 注意:默认允许透传,可通过 disable_store 禁用;禁用后可能导致 Codex 无法正常使用
|
||||
@@ -100,10 +100,10 @@ type GeneralOpenAIRequest struct {
|
||||
THINKING json.RawMessage `json:"thinking,omitempty"`
|
||||
// pplx Params
|
||||
SearchDomainFilter json.RawMessage `json:"search_domain_filter,omitempty"`
|
||||
SearchRecencyFilter string `json:"search_recency_filter,omitempty"`
|
||||
SearchRecencyFilter json.RawMessage `json:"search_recency_filter,omitempty"`
|
||||
ReturnImages *bool `json:"return_images,omitempty"`
|
||||
ReturnRelatedQuestions *bool `json:"return_related_questions,omitempty"`
|
||||
SearchMode string `json:"search_mode,omitempty"`
|
||||
SearchMode json.RawMessage `json:"search_mode,omitempty"`
|
||||
// Minimax
|
||||
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
|
||||
}
|
||||
@@ -836,7 +836,7 @@ type OpenAIResponsesRequest struct {
|
||||
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
||||
// SafetyIdentifier carries client identity for policy abuse detection.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_safety_identifier.
|
||||
SafetyIdentifier string `json:"safety_identifier,omitempty"`
|
||||
SafetyIdentifier json.RawMessage `json:"safety_identifier,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
@@ -844,8 +844,8 @@ type OpenAIResponsesRequest struct {
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Truncation json.RawMessage `json:"truncation,omitempty"`
|
||||
User json.RawMessage `json:"user,omitempty"`
|
||||
MaxToolCalls *uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
// qwen
|
||||
|
||||
@@ -267,7 +267,7 @@ type OpenAIResponsesResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
CreatedAt int `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
Status json.RawMessage `json:"status"`
|
||||
Error any `json:"error,omitempty"`
|
||||
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
|
||||
Instructions string `json:"instructions"`
|
||||
@@ -275,14 +275,14 @@ type OpenAIResponsesResponse struct {
|
||||
Model string `json:"model"`
|
||||
Output []ResponsesOutput `json:"output"`
|
||||
ParallelToolCalls bool `json:"parallel_tool_calls"`
|
||||
PreviousResponseID string `json:"previous_response_id"`
|
||||
PreviousResponseID json.RawMessage `json:"previous_response_id"`
|
||||
Reasoning *Reasoning `json:"reasoning"`
|
||||
Store bool `json:"store"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
ToolChoice string `json:"tool_choice"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice"`
|
||||
Tools []map[string]any `json:"tools"`
|
||||
TopP float64 `json:"top_p"`
|
||||
Truncation string `json:"truncation"`
|
||||
Truncation json.RawMessage `json:"truncation"`
|
||||
Usage *Usage `json:"usage"`
|
||||
User json.RawMessage `json:"user"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
|
||||
+23
-1
@@ -25,6 +25,11 @@ type Pricing struct {
|
||||
ModelPrice float64 `json:"model_price"`
|
||||
OwnerBy string `json:"owner_by"`
|
||||
CompletionRatio float64 `json:"completion_ratio"`
|
||||
CacheRatio *float64 `json:"cache_ratio,omitempty"`
|
||||
CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"`
|
||||
ImageRatio *float64 `json:"image_ratio,omitempty"`
|
||||
AudioRatio *float64 `json:"audio_ratio,omitempty"`
|
||||
AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
|
||||
EnableGroup []string `json:"enable_groups"`
|
||||
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
||||
PricingVersion string `json:"pricing_version,omitempty"`
|
||||
@@ -297,12 +302,29 @@ func updatePricing() {
|
||||
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
|
||||
pricing.QuotaType = 0
|
||||
}
|
||||
if cacheRatio, ok := ratio_setting.GetCacheRatio(model); ok {
|
||||
pricing.CacheRatio = &cacheRatio
|
||||
}
|
||||
if createCacheRatio, ok := ratio_setting.GetCreateCacheRatio(model); ok {
|
||||
pricing.CreateCacheRatio = &createCacheRatio
|
||||
}
|
||||
if imageRatio, ok := ratio_setting.GetImageRatio(model); ok {
|
||||
pricing.ImageRatio = &imageRatio
|
||||
}
|
||||
if ratio_setting.ContainsAudioRatio(model) {
|
||||
audioRatio := ratio_setting.GetAudioRatio(model)
|
||||
pricing.AudioRatio = &audioRatio
|
||||
}
|
||||
if ratio_setting.ContainsAudioCompletionRatio(model) {
|
||||
audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model)
|
||||
pricing.AudioCompletionRatio = &audioCompletionRatio
|
||||
}
|
||||
pricingMap = append(pricingMap, pricing)
|
||||
}
|
||||
|
||||
// 防止大更新后数据不通用
|
||||
if len(pricingMap) > 0 {
|
||||
pricingMap[0].PricingVersion = "82c4a357505fff6fee8462c3f7ec8a645bb95532669cb73b2cabee6a416ec24f"
|
||||
pricingMap[0].PricingVersion = "5a90f2b86c08bd983a9a2e6d66c255f4eaef9c4bc934386d2b6ae84ef0ff1f1f"
|
||||
}
|
||||
|
||||
// 刷新缓存映射,供高并发快速查询
|
||||
|
||||
+22
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ func getHeaderPassthroughRegex(pattern string) (*regexp.Regexp, error) {
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
func IsHeaderPassthroughRuleKey(key string) bool {
|
||||
return isHeaderPassthroughRuleKey(key)
|
||||
}
|
||||
func isHeaderPassthroughRuleKey(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
|
||||
+11
-10
@@ -1,6 +1,7 @@
|
||||
package baidu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
@@ -12,16 +13,16 @@ type BaiduMessage struct {
|
||||
}
|
||||
|
||||
type BaiduChatRequest struct {
|
||||
Messages []BaiduMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
PenaltyScore float64 `json:"penalty_score,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
System string `json:"system,omitempty"`
|
||||
DisableSearch bool `json:"disable_search,omitempty"`
|
||||
EnableCitation bool `json:"enable_citation,omitempty"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||
UserId string `json:"user_id,omitempty"`
|
||||
Messages []BaiduMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
PenaltyScore float64 `json:"penalty_score,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
System string `json:"system,omitempty"`
|
||||
DisableSearch bool `json:"disable_search,omitempty"`
|
||||
EnableCitation bool `json:"enable_citation,omitempty"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||
UserId json.RawMessage `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,7 +17,7 @@ type CozeEnterMessage struct {
|
||||
|
||||
type CozeChatRequest struct {
|
||||
BotId string `json:"bot_id"`
|
||||
UserId string `json:"user_id"`
|
||||
UserId json.RawMessage `json:"user_id"`
|
||||
AdditionalMessages []CozeEnterMessage `json:"additional_messages,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
CustomVariables json.RawMessage `json:"custom_variables,omitempty"`
|
||||
|
||||
@@ -34,8 +34,8 @@ func convertCozeChatRequest(c *gin.Context, request dto.GeneralOpenAIRequest) *C
|
||||
}
|
||||
}
|
||||
user := request.User
|
||||
if user == "" {
|
||||
user = helper.GetResponseID(c)
|
||||
if len(user) == 0 {
|
||||
user = json.RawMessage(helper.GetResponseID(c))
|
||||
}
|
||||
cozeRequest := &CozeChatRequest{
|
||||
BotId: c.GetString("bot_id"),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dify
|
||||
|
||||
import "github.com/QuantumNous/new-api/dto"
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
type DifyChatRequest struct {
|
||||
Inputs map[string]interface{} `json:"inputs"`
|
||||
|
||||
@@ -131,10 +131,16 @@ func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto
|
||||
}
|
||||
|
||||
user := request.User
|
||||
if user == "" {
|
||||
user = helper.GetResponseID(c)
|
||||
if len(user) == 0 {
|
||||
user = json.RawMessage(helper.GetResponseID(c))
|
||||
}
|
||||
difyReq.User = user
|
||||
var stringUser string
|
||||
err := json.Unmarshal(user, &stringUser)
|
||||
if err != nil {
|
||||
common.SysLog("failed to unmarshal user: " + err.Error())
|
||||
stringUser = helper.GetResponseID(c)
|
||||
}
|
||||
difyReq.User = stringUser
|
||||
|
||||
files := make([]DifyFile, 0)
|
||||
var content strings.Builder
|
||||
|
||||
@@ -59,7 +59,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
if !strings.HasPrefix(info.UpstreamModelName, "imagen") {
|
||||
return nil, errors.New("not supported model for image generation")
|
||||
return nil, errors.New("not supported model for image generation, only imagen models are supported")
|
||||
}
|
||||
|
||||
// convert size to aspect ratio but allow user to specify aspect ratio
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -298,6 +302,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
|
||||
reasoning := openrouter.RequestReasoning{
|
||||
Enabled: true,
|
||||
MaxTokens: *thinking.BudgetTokens,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,6 +3,7 @@ package openrouter
|
||||
import "encoding/json"
|
||||
|
||||
type RequestReasoning struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// One of the following (not both):
|
||||
Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style)
|
||||
MaxTokens int `json:"max_tokens,omitempty"` // Specific token limit (Anthropic-style)
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -80,15 +82,28 @@ type responsePayload struct {
|
||||
TaskId string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
TaskResult struct {
|
||||
TaskInfo struct {
|
||||
ExternalTaskId string `json:"external_task_id"`
|
||||
} `json:"task_info"`
|
||||
WatermarkInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"watermark_info"`
|
||||
TaskResult struct {
|
||||
Videos []struct {
|
||||
Id string `json:"id"`
|
||||
Url string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
Id string `json:"id"`
|
||||
Url string `json:"url"`
|
||||
WatermarkUrl string `json:"watermark_url"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"videos"`
|
||||
Images []struct {
|
||||
Index int `json:"index"`
|
||||
Url string `json:"url"`
|
||||
WatermarkUrl string `json:"watermark_url"`
|
||||
} `json:"images"`
|
||||
} `json:"task_result"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
FinalUnitDeduction string `json:"final_unit_deduction"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -338,15 +353,22 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
|
||||
taskInfo.Status = model.TaskStatusInProgress
|
||||
case "succeed":
|
||||
taskInfo.Status = model.TaskStatusSuccess
|
||||
if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 {
|
||||
video := videos[0]
|
||||
taskInfo.Url = video.Url
|
||||
}
|
||||
if tokens, err := strconv.ParseFloat(resPayload.Data.FinalUnitDeduction, 64); err == nil {
|
||||
rounded := int(math.Ceil(tokens))
|
||||
if rounded > 0 {
|
||||
taskInfo.CompletionTokens = rounded
|
||||
taskInfo.TotalTokens = rounded
|
||||
}
|
||||
}
|
||||
case "failed":
|
||||
taskInfo.Status = model.TaskStatusFailure
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown task status: %s", status)
|
||||
}
|
||||
if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 {
|
||||
video := videos[0]
|
||||
taskInfo.Url = video.Url
|
||||
}
|
||||
return taskInfo, nil
|
||||
}
|
||||
|
||||
@@ -383,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,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"
|
||||
|
||||
@@ -59,7 +59,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
Type: "adaptive",
|
||||
}
|
||||
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
request.TopP = common.GetPointer[float64](0)
|
||||
request.Temperature = common.GetPointer[float64](1.0)
|
||||
info.UpstreamModelName = request.Model
|
||||
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
|
||||
@@ -77,7 +76,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
// TODO: 临时处理
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
request.TopP = common.GetPointer[float64](0)
|
||||
request.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
|
||||
|
||||
+450
-28
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -20,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
|
||||
@@ -117,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 {
|
||||
@@ -124,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
|
||||
}
|
||||
@@ -136,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{} {
|
||||
@@ -160,14 +175,200 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte,
|
||||
}
|
||||
|
||||
overrideCtx := BuildParamOverrideContext(info)
|
||||
var recorder *paramOverrideAuditRecorder
|
||||
if shouldEnableParamOverrideAudit(paramOverride) {
|
||||
recorder = ¶mOverrideAuditRecorder{}
|
||||
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
|
||||
@@ -454,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 {
|
||||
@@ -463,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)
|
||||
@@ -470,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)
|
||||
@@ -487,19 +690,44 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
}
|
||||
// 处理路径中的负数索引
|
||||
opPath := processNegativeIndex(result, op.Path)
|
||||
var opPaths []string
|
||||
if isPathBasedOperation(op.Mode) {
|
||||
opPaths, err = resolveOperationPaths(result, opPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(opPaths) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch op.Mode {
|
||||
case "delete":
|
||||
result, err = sjson.Delete(result, opPath)
|
||||
case "set":
|
||||
if op.KeepOrigin && gjson.Get(result, opPath).Exists() {
|
||||
continue
|
||||
for _, path := range opPaths {
|
||||
result, err = deleteValue(result, path)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("delete", path, "", "", nil)
|
||||
}
|
||||
case "set":
|
||||
for _, path := range opPaths {
|
||||
if op.KeepOrigin && gjson.Get(result, path).Exists() {
|
||||
continue
|
||||
}
|
||||
result, err = sjson.Set(result, path, op.Value)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("set", path, "", "", op.Value)
|
||||
}
|
||||
result, err = sjson.Set(result, opPath, 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")
|
||||
@@ -507,44 +735,121 @@ 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":
|
||||
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true)
|
||||
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":
|
||||
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false)
|
||||
for _, path := range opPaths {
|
||||
result, err = modifyValue(result, path, op.Value, op.KeepOrigin, false)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("append", path, "", "", op.Value)
|
||||
}
|
||||
case "trim_prefix":
|
||||
result, err = trimStringValue(result, opPath, op.Value, true)
|
||||
for _, path := range opPaths {
|
||||
result, err = trimStringValue(result, path, op.Value, true)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("trim_prefix", path, "", "", op.Value)
|
||||
}
|
||||
case "trim_suffix":
|
||||
result, err = trimStringValue(result, opPath, op.Value, false)
|
||||
for _, path := range opPaths {
|
||||
result, err = trimStringValue(result, path, op.Value, false)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("trim_suffix", path, "", "", op.Value)
|
||||
}
|
||||
case "ensure_prefix":
|
||||
result, err = ensureStringAffix(result, opPath, op.Value, true)
|
||||
for _, path := range opPaths {
|
||||
result, err = ensureStringAffix(result, path, op.Value, true)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("ensure_prefix", path, "", "", op.Value)
|
||||
}
|
||||
case "ensure_suffix":
|
||||
result, err = ensureStringAffix(result, opPath, op.Value, false)
|
||||
for _, path := range opPaths {
|
||||
result, err = ensureStringAffix(result, path, op.Value, false)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("ensure_suffix", path, "", "", op.Value)
|
||||
}
|
||||
case "trim_space":
|
||||
result, err = transformStringValue(result, opPath, strings.TrimSpace)
|
||||
for _, path := range opPaths {
|
||||
result, err = transformStringValue(result, path, strings.TrimSpace)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("trim_space", path, "", "", nil)
|
||||
}
|
||||
case "to_lower":
|
||||
result, err = transformStringValue(result, opPath, strings.ToLower)
|
||||
for _, path := range opPaths {
|
||||
result, err = transformStringValue(result, path, strings.ToLower)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("to_lower", path, "", "", nil)
|
||||
}
|
||||
case "to_upper":
|
||||
result, err = transformStringValue(result, opPath, strings.ToUpper)
|
||||
for _, path := range opPaths {
|
||||
result, err = transformStringValue(result, path, strings.ToUpper)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("to_upper", path, "", "", nil)
|
||||
}
|
||||
case "replace":
|
||||
result, err = replaceStringValue(result, opPath, op.From, op.To)
|
||||
for _, path := range opPaths {
|
||||
result, err = replaceStringValue(result, path, op.From, op.To)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
auditRecorder.recordOperation("replace", path, op.From, op.To, nil)
|
||||
}
|
||||
case "regex_replace":
|
||||
result, err = regexReplaceStringValue(result, opPath, op.From, op.To)
|
||||
for _, path := range opPaths {
|
||||
result, err = regexReplaceStringValue(result, path, op.From, op.To)
|
||||
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
|
||||
}
|
||||
return "", returnErr
|
||||
case "prune_objects":
|
||||
result, err = pruneObjects(result, opPath, contextJSON, op.Value)
|
||||
for _, path := range opPaths {
|
||||
result, err = pruneObjects(result, path, contextJSON, op.Value)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
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":
|
||||
@@ -561,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":
|
||||
@@ -577,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":
|
||||
@@ -594,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:
|
||||
@@ -766,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
|
||||
}
|
||||
@@ -794,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
|
||||
@@ -801,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:
|
||||
@@ -1174,6 +1510,92 @@ func copyValue(jsonStr, fromPath, toPath string) (string, error) {
|
||||
return sjson.Set(jsonStr, toPath, sourceValue.Value())
|
||||
}
|
||||
|
||||
func isPathBasedOperation(mode string) bool {
|
||||
switch mode {
|
||||
case "delete", "set", "prepend", "append", "trim_prefix", "trim_suffix", "ensure_prefix", "ensure_suffix", "trim_space", "to_lower", "to_upper", "replace", "regex_replace", "prune_objects":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOperationPaths(jsonStr, path string) ([]string, error) {
|
||||
if !strings.Contains(path, "*") {
|
||||
return []string{path}, nil
|
||||
}
|
||||
return expandWildcardPaths(jsonStr, path)
|
||||
}
|
||||
|
||||
func expandWildcardPaths(jsonStr, path string) ([]string, error) {
|
||||
var root interface{}
|
||||
if err := common.Unmarshal([]byte(jsonStr), &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
segments := strings.Split(path, ".")
|
||||
paths := collectWildcardPaths(root, segments, nil)
|
||||
return lo.Uniq(paths), nil
|
||||
}
|
||||
|
||||
func collectWildcardPaths(node interface{}, segments []string, prefix []string) []string {
|
||||
if len(segments) == 0 {
|
||||
return []string{strings.Join(prefix, ".")}
|
||||
}
|
||||
|
||||
segment := strings.TrimSpace(segments[0])
|
||||
if segment == "" {
|
||||
return nil
|
||||
}
|
||||
isLast := len(segments) == 1
|
||||
|
||||
if segment == "*" {
|
||||
switch typed := node.(type) {
|
||||
case map[string]interface{}:
|
||||
keys := lo.Keys(typed)
|
||||
sort.Strings(keys)
|
||||
return lo.FlatMap(keys, func(key string, _ int) []string {
|
||||
return collectWildcardPaths(typed[key], segments[1:], append(prefix, key))
|
||||
})
|
||||
case []interface{}:
|
||||
return lo.FlatMap(lo.Range(len(typed)), func(index int, _ int) []string {
|
||||
return collectWildcardPaths(typed[index], segments[1:], append(prefix, strconv.Itoa(index)))
|
||||
})
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch typed := node.(type) {
|
||||
case map[string]interface{}:
|
||||
if isLast {
|
||||
return []string{strings.Join(append(prefix, segment), ".")}
|
||||
}
|
||||
next, exists := typed[segment]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
return collectWildcardPaths(next, segments[1:], append(prefix, segment))
|
||||
case []interface{}:
|
||||
index, err := strconv.Atoi(segment)
|
||||
if err != nil || index < 0 || index >= len(typed) {
|
||||
return nil
|
||||
}
|
||||
if isLast {
|
||||
return []string{strings.Join(append(prefix, segment), ".")}
|
||||
}
|
||||
return collectWildcardPaths(typed[index], segments[1:], append(prefix, segment))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func deleteValue(jsonStr, path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return jsonStr, nil
|
||||
}
|
||||
return sjson.Delete(jsonStr, path)
|
||||
}
|
||||
|
||||
func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
switch {
|
||||
|
||||
@@ -2,13 +2,16 @@ package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
common2 "github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func TestApplyParamOverrideTrimPrefix(t *testing.T) {
|
||||
@@ -242,6 +245,224 @@ func TestApplyParamOverrideDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideDeleteWildcardPath(t *testing.T) {
|
||||
input := []byte(`{"tools":[{"type":"bash","custom":{"input_examples":["a"],"other":1}},{"type":"code","custom":{"input_examples":["b"]}},{"type":"noop","custom":{"other":2}}]}`)
|
||||
override := map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"path": "tools.*.custom.input_examples",
|
||||
"mode": "delete",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ApplyParamOverride(input, override, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, `{"tools":[{"type":"bash","custom":{"other":1}},{"type":"code","custom":{}},{"type":"noop","custom":{"other":2}}]}`, string(out))
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideSetWildcardPath(t *testing.T) {
|
||||
input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B"}},{"custom":{"tag":"C"}}]}`)
|
||||
override := map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"path": "tools.*.custom.enabled",
|
||||
"mode": "set",
|
||||
"value": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ApplyParamOverride(input, override, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Tools []struct {
|
||||
Custom struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"custom"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("failed to unmarshal output JSON: %v", err)
|
||||
}
|
||||
|
||||
if !lo.EveryBy(got.Tools, func(item struct {
|
||||
Custom struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"custom"`
|
||||
}) bool {
|
||||
return item.Custom.Enabled
|
||||
}) {
|
||||
t.Fatalf("expected wildcard set to enable all tools, got: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideTrimSpaceWildcardPath(t *testing.T) {
|
||||
input := []byte(`{"tools":[{"custom":{"name":" alpha "}},{"custom":{"name":" beta"}},{"custom":{"name":"gamma "}}]}`)
|
||||
override := map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"path": "tools.*.custom.name",
|
||||
"mode": "trim_space",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ApplyParamOverride(input, override, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Tools []struct {
|
||||
Custom struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"custom"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("failed to unmarshal output JSON: %v", err)
|
||||
}
|
||||
|
||||
names := lo.Map(got.Tools, func(item struct {
|
||||
Custom struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"custom"`
|
||||
}, _ int) string {
|
||||
return item.Custom.Name
|
||||
})
|
||||
if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) {
|
||||
t.Fatalf("unexpected names after wildcard trim_space: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideDeleteWildcardEqualsIndexedPaths(t *testing.T) {
|
||||
input := []byte(`{"tools":[{"custom":{"input_examples":["a"],"other":1}},{"custom":{"input_examples":["b"],"other":2}},{"custom":{"input_examples":["c"],"other":3}}]}`)
|
||||
|
||||
wildcardOverride := map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"path": "tools.*.custom.input_examples",
|
||||
"mode": "delete",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
indexedOverride := map[string]interface{}{
|
||||
"operations": lo.Map(lo.Range(3), func(index int, _ int) interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": fmt.Sprintf("tools.%d.custom.input_examples", index),
|
||||
"mode": "delete",
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
wildcardOut, err := ApplyParamOverride(input, wildcardOverride, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("wildcard ApplyParamOverride returned error: %v", err)
|
||||
}
|
||||
|
||||
indexedOut, err := ApplyParamOverride(input, indexedOverride, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("indexed ApplyParamOverride returned error: %v", err)
|
||||
}
|
||||
|
||||
assertJSONEqual(t, string(indexedOut), string(wildcardOut))
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideSetWildcardKeepOrigin(t *testing.T) {
|
||||
input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B","enabled":false}},{"custom":{"tag":"C"}}]}`)
|
||||
override := map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"path": "tools.*.custom.enabled",
|
||||
"mode": "set",
|
||||
"value": true,
|
||||
"keep_origin": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ApplyParamOverride(input, override, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Tools []struct {
|
||||
Custom struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"custom"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("failed to unmarshal output JSON: %v", err)
|
||||
}
|
||||
|
||||
enabledValues := lo.Map(got.Tools, func(item struct {
|
||||
Custom struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"custom"`
|
||||
}, _ int) bool {
|
||||
return item.Custom.Enabled
|
||||
})
|
||||
if !reflect.DeepEqual(enabledValues, []bool{true, false, true}) {
|
||||
t.Fatalf("unexpected enabled values after wildcard keep_origin set: %v", enabledValues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideTrimSpaceMultiWildcardPath(t *testing.T) {
|
||||
input := []byte(`{"tools":[{"custom":{"items":[{"name":" alpha "},{"name":" beta "}]}},{"custom":{"items":[{"name":" gamma"}]}}]}`)
|
||||
override := map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"path": "tools.*.custom.items.*.name",
|
||||
"mode": "trim_space",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ApplyParamOverride(input, override, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParamOverride returned error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Tools []struct {
|
||||
Custom struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
} `json:"custom"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("failed to unmarshal output JSON: %v", err)
|
||||
}
|
||||
|
||||
names := lo.FlatMap(got.Tools, func(tool struct {
|
||||
Custom struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
} `json:"custom"`
|
||||
}, _ int) []string {
|
||||
return lo.Map(tool.Custom.Items, func(item struct {
|
||||
Name string `json:"name"`
|
||||
}, _ int) string {
|
||||
return item.Name
|
||||
})
|
||||
})
|
||||
if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) {
|
||||
t.Fatalf("unexpected names after multi wildcard trim_space: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideSet(t *testing.T) {
|
||||
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
|
||||
override := map[string]interface{}{
|
||||
@@ -261,6 +482,42 @@ func TestApplyParamOverrideSet(t *testing.T) {
|
||||
assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out))
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideSetWithDescriptionKeepsCompatibility(t *testing.T) {
|
||||
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
|
||||
overrideWithoutDesc := map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"path": "temperature",
|
||||
"mode": "set",
|
||||
"value": 0.1,
|
||||
},
|
||||
},
|
||||
}
|
||||
overrideWithDesc := map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"description": "set temperature for deterministic output",
|
||||
"path": "temperature",
|
||||
"mode": "set",
|
||||
"value": 0.1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
outWithoutDesc, err := ApplyParamOverride(input, overrideWithoutDesc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParamOverride without description returned error: %v", err)
|
||||
}
|
||||
|
||||
outWithDesc, err := ApplyParamOverride(input, overrideWithDesc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParamOverride with description returned error: %v", err)
|
||||
}
|
||||
|
||||
assertJSONEqual(t, string(outWithoutDesc), string(outWithDesc))
|
||||
assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(outWithDesc))
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideSetKeepOrigin(t *testing.T) {
|
||||
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
|
||||
override := map[string]interface{}{
|
||||
@@ -1397,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{}{
|
||||
@@ -1675,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()
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ type RelayInfo struct {
|
||||
LastError *types.NewAPIError
|
||||
RuntimeHeadersOverride map[string]interface{}
|
||||
UseRuntimeHeadersOverride bool
|
||||
ParamOverrideAudit []string
|
||||
|
||||
PriceData types.PriceData
|
||||
|
||||
|
||||
+8
-10
@@ -147,24 +147,22 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
|
||||
// 如果没有配置价格,检查模型倍率配置
|
||||
if !success {
|
||||
|
||||
// 没有配置费用,返回错误
|
||||
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
|
||||
if !ok {
|
||||
// 不再使用默认价格,而是返回错误
|
||||
return types.PriceData{}, fmt.Errorf("模型 %s 价格未配置,请联系管理员设置", info.OriginModelName)
|
||||
} else {
|
||||
if ok {
|
||||
modelPrice = defaultPrice
|
||||
}
|
||||
// 没有配置倍率也不接受没配置,那就返回错误
|
||||
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
|
||||
if !ratioSuccess {
|
||||
} else {
|
||||
// 没有配置倍率也不接受没配置,那就返回错误
|
||||
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
|
||||
acceptUnsetRatio := false
|
||||
if info.UserSetting.AcceptUnsetRatioModel {
|
||||
acceptUnsetRatio = true
|
||||
}
|
||||
if !acceptUnsetRatio {
|
||||
if !ratioSuccess && !acceptUnsetRatio {
|
||||
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
|
||||
}
|
||||
// 未配置价格但配置了倍率,使用默认预扣价格
|
||||
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -248,6 +248,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-10
@@ -34,22 +34,34 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
|
||||
|
||||
isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter
|
||||
|
||||
if claudeRequest.Thinking != nil && claudeRequest.Thinking.Type == "enabled" {
|
||||
if isOpenRouter {
|
||||
reasoning := openrouter.RequestReasoning{
|
||||
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
|
||||
if isOpenRouter {
|
||||
if effort := claudeRequest.GetEfforts(); effort != "" {
|
||||
effortBytes, _ := json.Marshal(effort)
|
||||
openAIRequest.Verbosity = effortBytes
|
||||
}
|
||||
if claudeRequest.Thinking != nil {
|
||||
var reasoning openrouter.RequestReasoning
|
||||
if claudeRequest.Thinking.Type == "enabled" {
|
||||
reasoning = openrouter.RequestReasoning{
|
||||
Enabled: true,
|
||||
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
|
||||
}
|
||||
} else if claudeRequest.Thinking.Type == "adaptive" {
|
||||
reasoning = openrouter.RequestReasoning{
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
reasoningJSON, err := json.Marshal(reasoning)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
|
||||
}
|
||||
openAIRequest.Reasoning = reasoningJSON
|
||||
} else {
|
||||
thinkingSuffix := "-thinking"
|
||||
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
|
||||
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
|
||||
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
|
||||
}
|
||||
}
|
||||
} else {
|
||||
thinkingSuffix := "-thinking"
|
||||
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
|
||||
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
|
||||
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -222,13 +222,13 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
|
||||
}
|
||||
other := taskBillingOther(task)
|
||||
other["task_id"] = task.TaskID
|
||||
other["reason"] = reason
|
||||
//other["reason"] = reason
|
||||
other["pre_consumed_quota"] = preConsumedQuota
|
||||
other["actual_quota"] = actualQuota
|
||||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||||
UserId: task.UserId,
|
||||
LogType: logType,
|
||||
Content: "",
|
||||
Content: reason,
|
||||
ChannelId: task.ChannelId,
|
||||
ModelName: taskModelName(task),
|
||||
Quota: logQuota,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
type StatusCodeRange struct {
|
||||
@@ -31,6 +33,10 @@ var alwaysSkipRetryStatusCodes = map[int]struct{}{
|
||||
524: {},
|
||||
}
|
||||
|
||||
var alwaysSkipRetryCodes = map[types.ErrorCode]struct{}{
|
||||
types.ErrorCodeBadResponseBody: {},
|
||||
}
|
||||
|
||||
func AutomaticDisableStatusCodesToString() string {
|
||||
return statusCodeRangesToString(AutomaticDisableStatusCodeRanges)
|
||||
}
|
||||
@@ -66,6 +72,11 @@ func IsAlwaysSkipRetryStatusCode(code int) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
func IsAlwaysSkipRetryCode(errorCode types.ErrorCode) bool {
|
||||
_, exists := alwaysSkipRetryCodes[errorCode]
|
||||
return exists
|
||||
}
|
||||
|
||||
func ShouldRetryByStatusCode(code int) bool {
|
||||
if IsAlwaysSkipRetryStatusCode(code) {
|
||||
return false
|
||||
|
||||
@@ -452,6 +452,44 @@ func GetCompletionRatio(name string) float64 {
|
||||
return hardCodedRatio
|
||||
}
|
||||
|
||||
type CompletionRatioInfo struct {
|
||||
Ratio float64 `json:"ratio"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
func GetCompletionRatioInfo(name string) CompletionRatioInfo {
|
||||
name = FormatMatchingModelName(name)
|
||||
|
||||
if strings.Contains(name, "/") {
|
||||
if ratio, ok := completionRatioMap.Get(name); ok {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: ratio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hardCodedRatio, locked := getHardcodedCompletionModelRatio(name)
|
||||
if locked {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: hardCodedRatio,
|
||||
Locked: true,
|
||||
}
|
||||
}
|
||||
|
||||
if ratio, ok := completionRatioMap.Get(name); ok {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: ratio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
return CompletionRatioInfo{
|
||||
Ratio: hardCodedRatio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
|
||||
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
|
||||
@@ -471,6 +509,9 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
}
|
||||
// gpt-5 匹配
|
||||
if strings.HasPrefix(name, "gpt-5") {
|
||||
if strings.HasPrefix(name, "gpt-5.4") {
|
||||
return 6, true
|
||||
}
|
||||
return 8, true
|
||||
}
|
||||
// gpt-4.5-preview匹配
|
||||
|
||||
Vendored
+1
-1
@@ -21,7 +21,7 @@ import { defineConfig } from 'i18next-cli';
|
||||
|
||||
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
||||
export default defineConfig({
|
||||
locales: ['zh', 'en', 'fr', 'ru', 'ja', 'vi'],
|
||||
locales: ['zh-CN', 'zh-TW', 'en', 'fr', 'ru', 'ja', 'vi'],
|
||||
extract: {
|
||||
input: ['src/**/*.{js,jsx,ts,tsx}'],
|
||||
ignore: ['src/i18n/**/*'],
|
||||
|
||||
Vendored
+7
-1
@@ -7,7 +7,13 @@
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
lang="zh"
|
||||
content="统一的 AI 模型聚合与分发网关,支持将各类大语言模型跨格式转换为 OpenAI、Claude、Gemini 兼容接口,为个人与企业提供集中式模型管理与网关服务。"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
lang="en"
|
||||
content="A unified AI model hub for aggregation & distribution. It supports cross-converting various LLMs into OpenAI-compatible, Claude-compatible, or Gemini-compatible formats. A centralized gateway for personal and enterprise model management."
|
||||
/>
|
||||
<meta name="generator" content="new-api" />
|
||||
<title>New API</title>
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
|
||||
import {
|
||||
Divider,
|
||||
Button,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Collapsible,
|
||||
@@ -46,6 +45,7 @@ import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
* @param {number} collapseHeight 折叠时的高度,默认200
|
||||
* @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
|
||||
* @param {boolean} loading 是否处于加载状态
|
||||
* @param {string} variant 颜色变体: 'violet' | 'teal' | 'amber' | 'rose' | 'green',不传则使用默认蓝色
|
||||
*/
|
||||
const SelectableButtonGroup = ({
|
||||
title,
|
||||
@@ -58,6 +58,7 @@ const SelectableButtonGroup = ({
|
||||
collapseHeight = 200,
|
||||
withCheckbox = false,
|
||||
loading = false,
|
||||
variant,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [skeletonCount] = useState(12);
|
||||
@@ -178,9 +179,6 @@ const SelectableButtonGroup = ({
|
||||
) : (
|
||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
||||
{items.map((item) => {
|
||||
const isDisabled =
|
||||
item.disabled ||
|
||||
(typeof item.tagCount === 'number' && item.tagCount === 0);
|
||||
const isActive = Array.isArray(activeValue)
|
||||
? activeValue.includes(item.value)
|
||||
: activeValue === item.value;
|
||||
@@ -194,13 +192,11 @@ const SelectableButtonGroup = ({
|
||||
}}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
className='sbg-button'
|
||||
icon={
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
onChange={() => onChange(item.value)}
|
||||
disabled={isDisabled}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
}
|
||||
@@ -210,14 +206,9 @@ const SelectableButtonGroup = ({
|
||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
||||
<ConditionalTooltipText text={item.label} />
|
||||
{item.tagCount !== undefined && shouldShowTags && (
|
||||
<Tag
|
||||
className='sbg-tag'
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
<span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -231,22 +222,16 @@ const SelectableButtonGroup = ({
|
||||
onClick={() => onChange(item.value)}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
className='sbg-button'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div className='sbg-content'>
|
||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
||||
<ConditionalTooltipText text={item.label} />
|
||||
{item.tagCount !== undefined && shouldShowTags && (
|
||||
<Tag
|
||||
className='sbg-tag'
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
{item.tagCount !== undefined && shouldShowTags && item.tagCount !== '' && (
|
||||
<span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -258,7 +243,7 @@ const SelectableButtonGroup = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`}
|
||||
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}${variant ? ` sbg-variant-${variant}` : ''}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
{title && (
|
||||
|
||||
@@ -37,10 +37,11 @@ import {
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
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();
|
||||
@@ -113,11 +114,34 @@ const PageLayout = () => {
|
||||
linkElement.href = logo;
|
||||
}
|
||||
}
|
||||
const savedLang = localStorage.getItem('i18nextLng');
|
||||
if (savedLang) {
|
||||
i18n.changeLanguage(savedLang);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
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
|
||||
|
||||
@@ -95,19 +95,19 @@ const RatioSetting = () => {
|
||||
|
||||
return (
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 模型倍率设置以及可视化编辑器 */}
|
||||
{/* 模型倍率设置以及价格编辑器 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='card'>
|
||||
<Tabs type='card' defaultActiveKey='visual'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
|
||||
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
|
||||
<GroupRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
|
||||
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
|
||||
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
|
||||
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Languages } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { API, showSuccess, showError } from "../../../../helpers";
|
||||
import { UserContext } from "../../../../context/User";
|
||||
import { normalizeLanguage } from "../../../../i18n/language";
|
||||
|
||||
// Language options with native names
|
||||
const languageOptions = [
|
||||
@@ -39,7 +40,7 @@ const PreferencesSettings = ({ t }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [currentLanguage, setCurrentLanguage] = useState(
|
||||
i18n.language || "zh-CN",
|
||||
normalizeLanguage(i18n.language) || "zh-CN",
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -49,8 +50,7 @@ const PreferencesSettings = ({ t }) => {
|
||||
try {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
if (settings.language) {
|
||||
// Normalize legacy "zh" to "zh-CN" for backward compatibility
|
||||
const lang = settings.language === "zh" ? "zh-CN" : settings.language;
|
||||
const lang = normalizeLanguage(settings.language);
|
||||
setCurrentLanguage(lang);
|
||||
// Sync i18n with saved preference
|
||||
if (i18n.language !== lang) {
|
||||
@@ -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'
|
||||
@@ -723,10 +728,6 @@ export const getChannelsColumns = ({
|
||||
name: t('仅检测上游模型更新'),
|
||||
type: 'tertiary',
|
||||
onClick: () => {
|
||||
if (!upstreamUpdateMeta.enabled) {
|
||||
showInfo(t('该渠道未开启上游模型更新检测'));
|
||||
return;
|
||||
}
|
||||
detectChannelUpstreamUpdates(record);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3291,6 +3291,18 @@ const EditChannelModal = (props) => {
|
||||
inputs.upstream_model_update_last_check_time,
|
||||
)}
|
||||
</div>
|
||||
<Form.Input
|
||||
field='upstream_model_update_ignored_models'
|
||||
label={t('已忽略模型')}
|
||||
placeholder={t('例如:gpt-4.1-nano,gpt-4o-mini')}
|
||||
onChange={(value) =>
|
||||
handleInputChange(
|
||||
'upstream_model_update_ignored_models',
|
||||
value,
|
||||
)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3460,19 +3472,6 @@ const EditChannelModal = (props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='upstream_model_update_ignored_models'
|
||||
label={t('手动忽略模型(逗号分隔)')}
|
||||
placeholder={t('例如:gpt-4.1-nano,gpt-4o-mini')}
|
||||
onChange={(value) =>
|
||||
handleInputChange(
|
||||
'upstream_model_update_ignored_models',
|
||||
value,
|
||||
)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<div className='text-xs text-gray-500 mb-3'>
|
||||
{t('上次检测到可加入模型')}:
|
||||
{upstreamDetectedModels.length === 0 ? (
|
||||
|
||||
@@ -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' },
|
||||
@@ -276,6 +283,7 @@ const LEGACY_TEMPLATE = {
|
||||
const OPERATION_TEMPLATE = {
|
||||
operations: [
|
||||
{
|
||||
description: 'Set default temperature for openai/* models.',
|
||||
path: 'temperature',
|
||||
mode: 'set',
|
||||
value: 0.7,
|
||||
@@ -294,8 +302,9 @@ const OPERATION_TEMPLATE = {
|
||||
const HEADER_PASSTHROUGH_TEMPLATE = {
|
||||
operations: [
|
||||
{
|
||||
description: 'Pass through X-Request-Id header to upstream.',
|
||||
mode: 'pass_headers',
|
||||
value: ['Authorization'],
|
||||
value: ['X-Request-Id'],
|
||||
keep_origin: true,
|
||||
},
|
||||
],
|
||||
@@ -304,6 +313,8 @@ const HEADER_PASSTHROUGH_TEMPLATE = {
|
||||
const GEMINI_IMAGE_4K_TEMPLATE = {
|
||||
operations: [
|
||||
{
|
||||
description:
|
||||
'Set imageSize to 4K when model contains gemini/image and ends with 4k.',
|
||||
mode: 'set',
|
||||
path: 'generationConfig.imageConfig.imageSize',
|
||||
value: '4K',
|
||||
@@ -311,7 +322,17 @@ const GEMINI_IMAGE_4K_TEMPLATE = {
|
||||
{
|
||||
path: 'original_model',
|
||||
mode: 'contains',
|
||||
value: 'gemini-3-pro-image-preview',
|
||||
value: 'gemini',
|
||||
},
|
||||
{
|
||||
path: 'original_model',
|
||||
mode: 'contains',
|
||||
value: 'image',
|
||||
},
|
||||
{
|
||||
path: 'original_model',
|
||||
mode: 'suffix',
|
||||
value: '4k',
|
||||
},
|
||||
],
|
||||
logic: 'AND',
|
||||
@@ -319,11 +340,13 @@ const GEMINI_IMAGE_4K_TEMPLATE = {
|
||||
],
|
||||
};
|
||||
|
||||
const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = {
|
||||
const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = {
|
||||
operations: [
|
||||
{
|
||||
description: 'Normalize anthropic-beta header tokens for Bedrock compatibility.',
|
||||
mode: 'set_header',
|
||||
path: 'anthropic-beta',
|
||||
// https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json
|
||||
value: {
|
||||
'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19',
|
||||
bash_20241022: null,
|
||||
@@ -353,8 +376,14 @@ const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_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
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Remove all tools[*].custom.input_examples before upstream relay.',
|
||||
mode: 'delete',
|
||||
path: 'tools.*.custom.input_examples',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -378,7 +407,7 @@ const TEMPLATE_PRESET_CONFIG = {
|
||||
},
|
||||
pass_headers_auth: {
|
||||
group: 'scenario',
|
||||
label: '请求头透传(Authorization)',
|
||||
label: '请求头透传(X-Request-Id)',
|
||||
kind: 'operations',
|
||||
payload: HEADER_PASSTHROUGH_TEMPLATE,
|
||||
},
|
||||
@@ -402,9 +431,9 @@ const TEMPLATE_PRESET_CONFIG = {
|
||||
},
|
||||
aws_bedrock_anthropic_beta_override: {
|
||||
group: 'scenario',
|
||||
label: 'AWS Bedrock anthropic-beta覆盖',
|
||||
label: 'AWS Bedrock Claude 兼容模板',
|
||||
kind: 'operations',
|
||||
payload: AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE,
|
||||
payload: AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -764,6 +793,7 @@ const createDefaultCondition = () => normalizeCondition({});
|
||||
|
||||
const normalizeOperation = (operation = {}) => ({
|
||||
id: nextLocalId(),
|
||||
description: typeof operation.description === 'string' ? operation.description : '',
|
||||
path: typeof operation.path === 'string' ? operation.path : '',
|
||||
mode: OPERATION_MODE_VALUES.has(operation.mode) ? operation.mode : 'set',
|
||||
value_text: toValueText(operation.value),
|
||||
@@ -778,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;
|
||||
@@ -1015,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('');
|
||||
@@ -1033,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');
|
||||
@@ -1040,6 +1109,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
setTemplateGroupKey('basic');
|
||||
setTemplatePresetKey('operations_default');
|
||||
}
|
||||
setHeaderValueExampleVisible(false);
|
||||
setFieldGuideVisible(false);
|
||||
setFieldGuideTarget('path');
|
||||
setFieldGuideKeyword('');
|
||||
@@ -1086,6 +1156,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
if (!keyword) return operations;
|
||||
return operations.filter((operation) => {
|
||||
const searchableText = [
|
||||
operation.description,
|
||||
operation.mode,
|
||||
operation.path,
|
||||
operation.from,
|
||||
@@ -1151,10 +1222,14 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
const payloadOps = filteredOps.map((operation) => {
|
||||
const mode = operation.mode || 'set';
|
||||
const meta = MODE_META[mode] || MODE_META.set;
|
||||
const descriptionValue = String(operation.description || '').trim();
|
||||
const pathValue = operation.path.trim();
|
||||
const fromValue = operation.from.trim();
|
||||
const toValue = operation.to.trim();
|
||||
const payload = { mode };
|
||||
if (descriptionValue) {
|
||||
payload.description = descriptionValue;
|
||||
}
|
||||
if (meta.path) {
|
||||
payload.path = pathValue;
|
||||
}
|
||||
@@ -1556,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) => {
|
||||
@@ -1563,6 +1699,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
if (index < 0) return prev;
|
||||
const source = prev[index];
|
||||
const cloned = normalizeOperation({
|
||||
description: source.description,
|
||||
path: source.path,
|
||||
mode: source.mode,
|
||||
value: parseLooseValue(source.value_text),
|
||||
@@ -1812,14 +1949,6 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='cursor-pointer select-none mt-1 whitespace-nowrap'
|
||||
onClick={() => openFieldGuide('path')}
|
||||
>
|
||||
{t('字段速查')}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1891,7 +2020,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
|
||||
<Input
|
||||
value={operationSearch}
|
||||
placeholder={t('搜索规则(类型 / 路径 / 来源 / 目标)')}
|
||||
placeholder={t('搜索规则(描述 / 类型 / 路径 / 来源 / 目标)')}
|
||||
onChange={(nextValue) =>
|
||||
setOperationSearch(nextValue || '')
|
||||
}
|
||||
@@ -1921,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' ||
|
||||
@@ -1946,18 +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>
|
||||
<IconMenu />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<Text strong>{`#${index + 1}`}</Text>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block mt-1'
|
||||
>
|
||||
{getOperationSummary(operation, index)}
|
||||
</Text>
|
||||
{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}
|
||||
@@ -2035,6 +2216,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
icon={<IconDelete />}
|
||||
aria-label={t('删除规则')}
|
||||
onClick={() =>
|
||||
removeOperation(selectedOperation.id)
|
||||
}
|
||||
@@ -2085,6 +2267,25 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
>
|
||||
{MODE_DESCRIPTIONS[mode] || ''}
|
||||
</Text>
|
||||
<div className='mt-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('规则描述(可选)')}
|
||||
</Text>
|
||||
<Input
|
||||
value={selectedOperation.description || ''}
|
||||
placeholder={t('例如:清理工具参数,避免上游校验错误')}
|
||||
onChange={(nextValue) =>
|
||||
updateOperation(selectedOperation.id, {
|
||||
description: nextValue || '',
|
||||
})
|
||||
}
|
||||
maxLength={180}
|
||||
showClear
|
||||
/>
|
||||
<Text type='tertiary' size='small' className='mt-1 block'>
|
||||
{`${String(selectedOperation.description || '').length}/180`}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{meta.value ? (
|
||||
mode === 'return_error' && returnErrorDraft ? (
|
||||
@@ -2631,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 }}
|
||||
@@ -2650,11 +2871,6 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
{getModeValueHelp(mode) ? (
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(getModeValueHelp(mode))}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
@@ -3110,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}
|
||||
|
||||
@@ -76,7 +76,6 @@ const PricingEndpointTypes = ({
|
||||
value: 'all',
|
||||
label: t('全部端点'),
|
||||
tagCount: getEndpointTypeCount('all'),
|
||||
disabled: models.length === 0,
|
||||
},
|
||||
...availableEndpointTypes.map((endpointType) => {
|
||||
const count = getEndpointTypeCount(endpointType);
|
||||
@@ -84,7 +83,6 @@ const PricingEndpointTypes = ({
|
||||
value: endpointType,
|
||||
label: getEndpointTypeLabel(endpointType),
|
||||
tagCount: count,
|
||||
disabled: count === 0,
|
||||
};
|
||||
}),
|
||||
];
|
||||
@@ -96,6 +94,7 @@ const PricingEndpointTypes = ({
|
||||
activeValue={filterEndpointType}
|
||||
onChange={setFilterEndpointType}
|
||||
loading={loading}
|
||||
variant='green'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -52,20 +52,19 @@ const PricingGroups = ({
|
||||
.length;
|
||||
let ratioDisplay = '';
|
||||
if (g === 'all') {
|
||||
ratioDisplay = t('全部');
|
||||
// ratioDisplay = t('全部');
|
||||
} else {
|
||||
const ratio = groupRatio[g];
|
||||
if (ratio !== undefined && ratio !== null) {
|
||||
ratioDisplay = `x${ratio}`;
|
||||
ratioDisplay = `${ratio}x`;
|
||||
} else {
|
||||
ratioDisplay = 'x1';
|
||||
ratioDisplay = '1x';
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: g,
|
||||
label: g === 'all' ? t('全部分组') : g,
|
||||
tagCount: ratioDisplay,
|
||||
disabled: modelCount === 0,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,6 +75,7 @@ const PricingGroups = ({
|
||||
activeValue={filterGroup}
|
||||
onChange={setFilterGroup}
|
||||
loading={loading}
|
||||
variant='teal'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -52,6 +52,7 @@ const PricingQuotaTypes = ({
|
||||
activeValue={filterQuotaType}
|
||||
onChange={setFilterQuotaType}
|
||||
loading={loading}
|
||||
variant='amber'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -78,7 +78,6 @@ const PricingTags = ({
|
||||
value: 'all',
|
||||
label: t('全部标签'),
|
||||
tagCount: getTagCount('all'),
|
||||
disabled: models.length === 0,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -88,7 +87,6 @@ const PricingTags = ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
tagCount: count,
|
||||
disabled: count === 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +100,7 @@ const PricingTags = ({
|
||||
activeValue={filterTag}
|
||||
onChange={setFilterTag}
|
||||
loading={loading}
|
||||
variant='rose'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,6 @@ const PricingVendors = ({
|
||||
value: 'all',
|
||||
label: t('全部供应商'),
|
||||
tagCount: getVendorCount('all'),
|
||||
disabled: models.length === 0,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -96,7 +95,6 @@ const PricingVendors = ({
|
||||
label: vendor,
|
||||
icon: icon ? getLobeHubIcon(icon, 16) : null,
|
||||
tagCount: count,
|
||||
disabled: count === 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +105,6 @@ const PricingVendors = ({
|
||||
value: 'unknown',
|
||||
label: t('未知供应商'),
|
||||
tagCount: count,
|
||||
disabled: count === 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +118,7 @@ const PricingVendors = ({
|
||||
activeValue={filterVendor}
|
||||
onChange={setFilterVendor}
|
||||
loading={loading}
|
||||
variant='violet'
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -113,15 +113,6 @@ const PricingSidebar = ({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingTags
|
||||
filterTag={filterTag}
|
||||
setFilterTag={setFilterTag}
|
||||
models={tagModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={handleGroupClick}
|
||||
@@ -140,6 +131,15 @@ const PricingSidebar = ({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingTags
|
||||
filterTag={filterTag}
|
||||
setFilterTag={setFilterTag}
|
||||
models={tagModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingEndpointTypes
|
||||
filterEndpointType={filterEndpointType}
|
||||
setFilterEndpointType={setFilterEndpointType}
|
||||
|
||||
@@ -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}
|
||||
@@ -96,15 +98,6 @@ const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingTags
|
||||
filterTag={filterTag}
|
||||
setFilterTag={setFilterTag}
|
||||
models={tagModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={setFilterGroup}
|
||||
@@ -123,6 +116,15 @@ const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingTags
|
||||
filterTag={filterTag}
|
||||
setFilterTag={setFilterTag}
|
||||
models={tagModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingEndpointTypes
|
||||
filterEndpointType={filterEndpointType}
|
||||
setFilterEndpointType={setFilterEndpointType}
|
||||
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Typography, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { IconCoinMoneyStroked } from '@douyinfe/semi-icons';
|
||||
import { calculateModelPrice } from '../../../../../helpers';
|
||||
import { calculateModelPrice, getModelPriceItems } from '../../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -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,12 +76,7 @@ const ModelPricingTable = ({
|
||||
: modelData?.quota_type === 1
|
||||
? t('按次计费')
|
||||
: '-',
|
||||
inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-',
|
||||
outputPrice:
|
||||
modelData?.quota_type === 0
|
||||
? priceData.completionPrice || priceData.outputPrice
|
||||
: '-',
|
||||
fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-',
|
||||
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -126,48 +123,22 @@ const ModelPricingTable = ({
|
||||
},
|
||||
});
|
||||
|
||||
// 根据计费类型添加价格列
|
||||
if (modelData?.quota_type === 0) {
|
||||
// 按量计费
|
||||
columns.push(
|
||||
{
|
||||
title: t('提示'),
|
||||
dataIndex: 'inputPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className='font-semibold text-orange-600'>{text}</div>
|
||||
<div className='text-xs text-gray-500'>
|
||||
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
|
||||
columns.push({
|
||||
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
|
||||
dataIndex: 'priceItems',
|
||||
render: (items) => (
|
||||
<div className='space-y-1'>
|
||||
{items.map((item) => (
|
||||
<div key={item.key}>
|
||||
<div className='font-semibold text-orange-600'>
|
||||
{item.label} {item.value}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('补全'),
|
||||
dataIndex: 'outputPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className='font-semibold text-orange-600'>{text}</div>
|
||||
<div className='text-xs text-gray-500'>
|
||||
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 按次计费
|
||||
columns.push({
|
||||
title: t('价格'),
|
||||
dataIndex: 'fixedPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className='font-semibold text-orange-600'>{text}</div>
|
||||
<div className='text-xs text-gray-500'>/ 次</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
<div className='text-xs text-gray-500'>{item.suffix}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Table
|
||||
|
||||
@@ -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 (
|
||||
@@ -264,8 +266,8 @@ const PricingCardView = ({
|
||||
<h3 className='text-lg font-bold text-gray-900 truncate'>
|
||||
{model.model_name}
|
||||
</h3>
|
||||
<div className='flex items-center gap-3 text-xs mt-1'>
|
||||
{formatPriceInfo(priceData, t)}
|
||||
<div className='flex flex-col gap-1 text-xs mt-1'>
|
||||
{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,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
renderModelTag,
|
||||
stringToColor,
|
||||
calculateModelPrice,
|
||||
getModelPriceItems,
|
||||
getLobeHubIcon,
|
||||
} from '../../../../../helpers';
|
||||
import {
|
||||
@@ -108,6 +109,7 @@ export const getPricingTableColumns = ({
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
siteDisplayType,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
@@ -125,6 +127,7 @@ export const getPricingTableColumns = ({
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType: siteDisplayType,
|
||||
});
|
||||
priceDataCache.set(record, cache);
|
||||
}
|
||||
@@ -226,31 +229,23 @@ 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, siteDisplayType);
|
||||
|
||||
if (priceData.isPerToken) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-gray-700'>
|
||||
{t('输入')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
{priceItems.map((item) => (
|
||||
<div key={item.key} className='text-gray-700'>
|
||||
{item.label} {item.value}
|
||||
{item.suffix}
|
||||
</div>
|
||||
<div className='text-gray-700'>
|
||||
{t('输出')} {priceData.completionPrice} / 1{priceData.unitLabel}{' '}
|
||||
tokens
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className='text-gray-700'>
|
||||
{t('模型价格')}:{priceData.price}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Vendored
+7
-2
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { reducer, initialState } from './reducer';
|
||||
import { normalizeLanguage } from '../../i18n/language';
|
||||
|
||||
export const UserContext = React.createContext({
|
||||
state: initialState,
|
||||
@@ -35,8 +36,12 @@ export const UserProvider = ({ children }) => {
|
||||
if (state.user?.setting) {
|
||||
try {
|
||||
const settings = JSON.parse(state.user.setting);
|
||||
if (settings.language && settings.language !== i18n.language) {
|
||||
i18n.changeLanguage(settings.language);
|
||||
const normalizedLanguage = normalizeLanguage(settings.language);
|
||||
if (normalizedLanguage && normalizedLanguage !== i18n.language) {
|
||||
i18n.changeLanguage(normalizedLanguage);
|
||||
}
|
||||
if (normalizedLanguage) {
|
||||
localStorage.setItem('i18nextLng', normalizedLanguage);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
|
||||
Vendored
+1403
-538
File diff suppressed because it is too large
Load Diff
Vendored
+22
-3
@@ -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 [];
|
||||
|
||||
Vendored
+181
-26
@@ -615,6 +615,7 @@ export const calculateModelPrice = ({
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
quotaDisplayType = 'USD',
|
||||
precision = 4,
|
||||
}) => {
|
||||
// 1. 选择实际使用的分组
|
||||
@@ -647,20 +648,34 @@ export const calculateModelPrice = ({
|
||||
// 2. 根据计费类型计算价格
|
||||
if (record.quota_type === 0) {
|
||||
// 按量计费
|
||||
const isTokensDisplay = quotaDisplayType === 'TOKENS';
|
||||
const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
|
||||
const completionRatioPriceUSD =
|
||||
record.model_ratio * record.completion_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 rawDisplayInput = displayPrice(inputRatioPriceUSD);
|
||||
const rawDisplayCompletion = displayPrice(completionRatioPriceUSD);
|
||||
const formatRatio = (value) =>
|
||||
hasRatioValue(value) ? Number(Number(value).toFixed(6)) : null;
|
||||
|
||||
const numInput =
|
||||
parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
const numCompletion =
|
||||
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
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') {
|
||||
@@ -678,11 +693,45 @@ export const calculateModelPrice = ({
|
||||
symbol = '¤';
|
||||
}
|
||||
}
|
||||
|
||||
const formatTokenPrice = (priceUSD) => {
|
||||
const rawDisplayPrice = displayPrice(priceUSD);
|
||||
const numericPrice =
|
||||
parseFloat(rawDisplayPrice.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
return `${symbol}${numericPrice.toFixed(precision)}`;
|
||||
};
|
||||
|
||||
const inputPrice = formatTokenPrice(inputRatioPriceUSD);
|
||||
const audioInputPrice = hasRatioValue(record.audio_ratio)
|
||||
? formatTokenPrice(inputRatioPriceUSD * Number(record.audio_ratio))
|
||||
: null;
|
||||
|
||||
return {
|
||||
inputPrice: `${symbol}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
|
||||
inputPrice,
|
||||
completionPrice: formatTokenPrice(
|
||||
inputRatioPriceUSD * Number(record.completion_ratio),
|
||||
),
|
||||
cachePrice: hasRatioValue(record.cache_ratio)
|
||||
? formatTokenPrice(inputRatioPriceUSD * Number(record.cache_ratio))
|
||||
: null,
|
||||
createCachePrice: hasRatioValue(record.create_cache_ratio)
|
||||
? formatTokenPrice(inputRatioPriceUSD * Number(record.create_cache_ratio))
|
||||
: null,
|
||||
imagePrice: hasRatioValue(record.image_ratio)
|
||||
? formatTokenPrice(inputRatioPriceUSD * Number(record.image_ratio))
|
||||
: null,
|
||||
audioInputPrice,
|
||||
audioOutputPrice:
|
||||
audioInputPrice && hasRatioValue(record.audio_completion_ratio)
|
||||
? formatTokenPrice(
|
||||
inputRatioPriceUSD *
|
||||
Number(record.audio_ratio) *
|
||||
Number(record.audio_completion_ratio),
|
||||
)
|
||||
: null,
|
||||
unitLabel,
|
||||
isPerToken: true,
|
||||
isTokensDisplay: false,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
@@ -696,6 +745,7 @@ export const calculateModelPrice = ({
|
||||
return {
|
||||
price: displayVal,
|
||||
isPerToken: false,
|
||||
isTokensDisplay: false,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
@@ -705,31 +755,136 @@ export const calculateModelPrice = ({
|
||||
return {
|
||||
price: '-',
|
||||
isPerToken: false,
|
||||
isTokensDisplay: false,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
};
|
||||
|
||||
// 格式化价格信息(用于卡片视图)
|
||||
export const formatPriceInfo = (priceData, t) => {
|
||||
export const getModelPriceItems = (
|
||||
priceData,
|
||||
t,
|
||||
quotaDisplayType = 'USD',
|
||||
) => {
|
||||
if (priceData.isPerToken) {
|
||||
return (
|
||||
<>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('输入')} {priceData.inputPrice}/{priceData.unitLabel}
|
||||
</span>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('输出')} {priceData.completionPrice}/{priceData.unitLabel}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
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 [
|
||||
{
|
||||
key: 'input',
|
||||
label: t('输入价格'),
|
||||
value: priceData.inputPrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'completion',
|
||||
label: t('补全价格'),
|
||||
value: priceData.completionPrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
label: t('缓存读取价格'),
|
||||
value: priceData.cachePrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'create-cache',
|
||||
label: t('缓存创建价格'),
|
||||
value: priceData.createCachePrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
label: t('图片输入价格'),
|
||||
value: priceData.imagePrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'audio-input',
|
||||
label: t('音频输入价格'),
|
||||
value: priceData.audioInputPrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
{
|
||||
key: 'audio-output',
|
||||
label: t('音频补全价格'),
|
||||
value: priceData.audioOutputPrice,
|
||||
suffix: unitSuffix,
|
||||
},
|
||||
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'fixed',
|
||||
label: t('模型价格'),
|
||||
value: priceData.price,
|
||||
suffix: ` / ${t('次')}`,
|
||||
},
|
||||
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
||||
};
|
||||
|
||||
// 格式化价格信息(用于卡片视图)
|
||||
export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
|
||||
const items = getModelPriceItems(priceData, t, quotaDisplayType);
|
||||
return (
|
||||
<>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('模型价格')} {priceData.price}
|
||||
</span>
|
||||
{items.map((item) => (
|
||||
<span key={item.key} style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{item.label} {item.value}
|
||||
{item.suffix}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+23
-2
@@ -21,6 +21,23 @@ import { useRef, useState } from 'react';
|
||||
import { API, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import { normalizeModelList } from './upstreamUpdateUtils';
|
||||
|
||||
const getManualIgnoredModelCountFromSettings = (settings) => {
|
||||
let parsed = null;
|
||||
if (settings && typeof settings === 'object') {
|
||||
parsed = settings;
|
||||
} else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings);
|
||||
} catch (error) {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return 0;
|
||||
}
|
||||
return normalizeModelList(parsed.upstream_model_update_ignored_models).length;
|
||||
};
|
||||
|
||||
export const useChannelUpstreamUpdates = ({ t, refresh }) => {
|
||||
const [showUpstreamUpdateModal, setShowUpstreamUpdateModal] = useState(false);
|
||||
const [upstreamUpdateChannel, setUpstreamUpdateChannel] = useState(null);
|
||||
@@ -114,14 +131,18 @@ export const useChannelUpstreamUpdates = ({ t, refresh }) => {
|
||||
|
||||
const addedCount = data?.added_models?.length || 0;
|
||||
const removedCount = data?.removed_models?.length || 0;
|
||||
const ignoredCount = data?.ignored_models?.length || 0;
|
||||
const totalIgnoredCount = getManualIgnoredModelCountFromSettings(
|
||||
data?.settings,
|
||||
);
|
||||
const ignoredCount = normalizeModelList(ignoreModels).length;
|
||||
showSuccess(
|
||||
t(
|
||||
'已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,忽略 {{ignored}} 个',
|
||||
'已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,本次忽略 {{ignored}} 个,当前已忽略模型 {{totalIgnored}} 个',
|
||||
{
|
||||
added: addedCount,
|
||||
removed: removedCount,
|
||||
ignored: ignoredCount,
|
||||
totalIgnored: totalIgnoredCount,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Vendored
+28
-15
@@ -24,6 +24,7 @@ import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useSetTheme, useTheme, useActualTheme } from '../../context/Theme';
|
||||
import { getLogo, getSystemName, API, showSuccess } from '../../helpers';
|
||||
import { normalizeLanguage } from '../../i18n/language';
|
||||
import { useIsMobile } from './useIsMobile';
|
||||
import { useSidebarCollapsed } from './useSidebarCollapsed';
|
||||
import { useMinimumLoadingTime } from './useMinimumLoadingTime';
|
||||
@@ -36,7 +37,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [currentLang, setCurrentLang] = useState(i18n.language);
|
||||
const [currentLang, setCurrentLang] = useState(normalizeLanguage(i18n.language));
|
||||
const location = useLocation();
|
||||
|
||||
const loading = statusState?.status === undefined;
|
||||
@@ -118,12 +119,13 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
// Language change effect
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng) => {
|
||||
setCurrentLang(lng);
|
||||
const normalizedLang = normalizeLanguage(lng);
|
||||
setCurrentLang(normalizedLang);
|
||||
try {
|
||||
const iframe = document.querySelector('iframe');
|
||||
const cw = iframe && iframe.contentWindow;
|
||||
if (cw) {
|
||||
cw.postMessage({ lang: lng }, '*');
|
||||
cw.postMessage({ lang: normalizedLang }, '*');
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore cross-origin or access errors
|
||||
@@ -148,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) {
|
||||
@@ -157,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
@@ -73,7 +73,7 @@ export const useModelPricingData = () => {
|
||||
[statusState],
|
||||
);
|
||||
|
||||
// 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币
|
||||
// 默认货币与站点展示类型同步;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
@@ -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
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user