Compare commits

..

20 Commits

Author SHA1 Message Date
CaIon
6ca4ff503e feat: remove unnecessary section for screenshots in bug report templates 2026-03-14 15:50:42 +08:00
CaIon
dea9353842 feat: update issue and feature request templates to include documentation links and submission checks 2026-03-14 15:48:50 +08:00
CaIon
4deb1eb70f feat: update ratio label for user group handling in render component 2026-03-14 15:41:02 +08:00
CaIon
092ee07e94 feat: normalize number handling in model pricing editor #3246 2026-03-14 15:29:47 +08:00
CaIon
4e1b05e987 feat: add conditional setting for HTTP headers in OpenRouter channel type 2026-03-12 19:05:30 +08:00
CaIon
3bd1a167c9 feat: comment out notify endpoint in relay router 2026-03-12 19:05:30 +08:00
Seefs
7a0ff73c1b Merge pull request #3221 from RedwindA/chore/updateModelList
chore: update model lists for frequently used channels
2026-03-12 15:13:03 +08:00
CaIon
902725eb66 feat: update header title for OpenRouter channel type 2026-03-12 15:05:58 +08:00
RedwindA
cd1b3771bf chore: update model lists for frequently used channels 2026-03-11 23:39:18 +08:00
Calcium-Ion
122d5c00ef Merge pull request #3182 from seefs001/feature/params-override-beta-header-append
feat:support $keep_only_declared and deduped $append for header override
2026-03-10 02:03:02 +08:00
Seefs
c3b9ae5f3b refactor: optimize header override copy and JSON example dialog 2026-03-10 01:59:34 +08:00
CaIon
2c9b22153f feat: enhance Claude request header handling with append functionality 2026-03-09 23:47:51 +08:00
Calcium-Ion
80c09d7119 Merge pull request #3147 from pigletfly/compose-add-networks
fix: add explicit docker-compose networks
2026-03-09 22:19:56 +08:00
Calcium-Ion
b0d8b563c3 Merge pull request #3148 from feitianbubu/pr/d8a25d36204224f8a4248b0ab3b03ba703796ea3
fix: kling risk fail return openAIVideo error
2026-03-09 22:19:04 +08:00
Seefs
6ff5a5dc99 feat:support $keep_only_declared and deduped $append for header token overrides 2026-03-09 00:12:53 +08:00
CaIon
9bb2b6a6ae feat: implement token key fetching and masking in API responses 2026-03-08 22:40:40 +08:00
Calcium-Ion
c706a5c29a Merge pull request #3166 from somnifex/main
为渠道参数覆盖可视化规则提供拖拽排序支持
2026-03-07 15:02:35 +08:00
somnifex
9b394231c8 feat: add drag-and-drop functionality for operation reordering in ParamOverrideEditorModal 2026-03-07 14:10:06 +08:00
feitianbubu
c3a96bc980 fix: kling risk fail return openAIVideo error 2026-03-06 16:32:52 +08:00
pigletfly
bb0d4b0f6d fix(compose): Add explicit bridge network 2026-03-06 15:44:47 +08:00
44 changed files with 1245 additions and 257 deletions

View File

@@ -7,14 +7,23 @@ assignees: ''
---
**例行检查**
## 提交前必读(请勿删除本节)
- 文档https://docs.newapi.ai/
- 使用问题先看或先问https://deepwiki.com/QuantumNous/new-api
- 警告:删除本模板、删除小节标题或随意清空内容的 issue可能会被直接关闭重复恶意提交者可能会被 block。
**您当前的 newapi 版本**
请填写,例如:`v1.0.0`
**提交确认**
[//]: # (方框内删除已有的空格,填 x 号)
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本
+ [ ] 我已完整查看过项目 README尤其是常见问题部分
+ [ ] 我理解并愿意跟进此 issue协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
+ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README尤其是常见问题部分
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
**问题描述**
@@ -23,4 +32,3 @@ assignees: ''
**预期结果**
**相关截图**
如果没有的话,请删除此节。

View File

@@ -7,14 +7,23 @@ assignees: ''
---
**Routine Checks**
## Read This First (Do Not Remove This Section)
- Docs: https://docs.newapi.ai/
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
**Your current newapi version**
Please fill this in, for example: `v1.0.0`
**Submission Checks**
[//]: # (Remove the space in the box and fill with an x)
+ [ ] I have confirmed there are no similar issues currently
+ [ ] I have confirmed I have upgraded to the latest version
+ [ ] I have thoroughly read the project README, especially the FAQ section
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
+ [ ] I have confirmed there are no similar issues
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, especially the FAQ section
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
**Issue Description**
@@ -23,4 +32,3 @@ assignees: ''
**Expected Result**
**Related Screenshots**
If none, please delete this section.

View File

@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 项目群聊
url: https://private-user-images.githubusercontent.com/61247483/283011625-de536a8a-0161-47a7-a0a2-66ef6de81266.jpeg?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTEiLCJleHAiOjE3MDIyMjQzOTAsIm5iZiI6MTcwMjIyNDA5MCwicGF0aCI6Ii82MTI0NzQ4My8yODMwMTE2MjUtZGU1MzZhOGEtMDE2MS00N2E3LWEwYTItNjZlZjZkZTgxMjY2LmpwZWc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBSVdOSllBWDRDU1ZFSDUzQSUyRjIwMjMxMjEwJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDIzMTIxMFQxNjAxMzBaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT02MGIxYmM3ZDQyYzBkOTA2ZTYyYmVmMzQ1NjY4NjM1YjY0NTUzNTM5NjE1NDZkYTIzODdhYTk4ZjZjODJmYzY2JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.TJ8CTfOSwR0-CHS1KLfomqgL0e4YH1luy8lSLrkv5Zg
about: QQ 群629454374
- name: 使用文档 / Documentation
url: https://docs.newapi.ai/
about: 提交 issue 前请先查阅文档,确认现有说明无法解决你的问题。
- name: 使用问题 / Usage Questions
url: https://deepwiki.com/QuantumNous/new-api
about: 使用、配置、接入等问题请优先在 DeepWiki 查询或提问。

View File

@@ -7,14 +7,23 @@ assignees: ''
---
**例行检查**
## 提交前必读(请勿删除本节)
- 文档https://docs.newapi.ai/
- 使用问题先看或先问https://deepwiki.com/QuantumNous/new-api
- 警告:删除本模板、删除小节标题或随意清空内容的 issue可能会被直接关闭重复恶意提交者可能会被 block。
**您当前的 newapi 版本**
请填写,例如:`v1.0.0`
**提交确认**
[//]: # (方框内删除已有的空格,填 x 号)
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本
+ [ ] 我已完整查看过项目 README已确定现有版本无法满足需求
+ [ ] 我理解并愿意跟进此 issue协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭**
+ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README已确定现有版本无法满足需求
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
**功能描述**

View File

@@ -7,16 +7,24 @@ assignees: ''
---
**Routine Checks**
## Read This First (Do Not Remove This Section)
- Docs: https://docs.newapi.ai/
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
**Your current newapi version**
Please fill this in, for example: `v1.0.0`
**Submission Checks**
[//]: # (Remove the space in the box and fill with an x)
+ [ ] I have confirmed there are no similar issues currently
+ [ ] I have confirmed I have upgraded to the latest version
+ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
+ [ ] I have confirmed there are no similar issues
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, and confirmed the current version cannot meet my needs
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
**Feature Description**
**Use Case**

2
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.idea
.vscode
.zed
.history
upload
*.exe
*.db
@@ -20,6 +21,7 @@ tiktoken_cache
.cache
web/bun.lock
plans
.claude
electron/node_modules
electron/dist

View File

@@ -14,6 +14,23 @@ import (
"github.com/gin-gonic/gin"
)
func buildMaskedTokenResponse(token *model.Token) *model.Token {
if token == nil {
return nil
}
maskedToken := *token
maskedToken.Key = token.GetMaskedKey()
return &maskedToken
}
func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
maskedTokens := make([]*model.Token, 0, len(tokens))
for _, token := range tokens {
maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
}
return maskedTokens
}
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
@@ -24,9 +41,8 @@ func GetAllTokens(c *gin.Context) {
}
total, _ := model.CountUserTokens(userId)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(tokens)
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
return
}
func SearchTokens(c *gin.Context) {
@@ -42,9 +58,8 @@ func SearchTokens(c *gin.Context) {
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(tokens)
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
return
}
func GetToken(c *gin.Context) {
@@ -59,12 +74,24 @@ func GetToken(c *gin.Context) {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": token,
common.ApiSuccess(c, buildMaskedTokenResponse(token))
}
func GetTokenKey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
if err != nil {
common.ApiError(c, err)
return
}
token, err := model.GetTokenByIds(id, userId)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"key": token.GetFullKey(),
})
return
}
func GetTokenStatus(c *gin.Context) {
@@ -204,7 +231,6 @@ func AddToken(c *gin.Context) {
"success": true,
"message": "",
})
return
}
func DeleteToken(c *gin.Context) {
@@ -219,7 +245,6 @@ func DeleteToken(c *gin.Context) {
"success": true,
"message": "",
})
return
}
func UpdateToken(c *gin.Context) {
@@ -283,7 +308,7 @@ func UpdateToken(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": cleanToken,
"data": buildMaskedTokenResponse(cleanToken),
})
}

275
controller/token_test.go Normal file
View File

@@ -0,0 +1,275 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type tokenAPIResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
type tokenPageResponse struct {
Items []tokenResponseItem `json:"items"`
}
type tokenResponseItem struct {
ID int `json:"id"`
Name string `json:"name"`
Key string `json:"key"`
Status int `json:"status"`
}
type tokenKeyResponse struct {
Key string `json:"key"`
}
func setupTokenControllerTestDB(t *testing.T) *gorm.DB {
t.Helper()
gin.SetMode(gin.TestMode)
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
model.DB = db
model.LOG_DB = db
if err := db.AutoMigrate(&model.Token{}); err != nil {
t.Fatalf("failed to migrate token table: %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func seedToken(t *testing.T, db *gorm.DB, userID int, name string, rawKey string) *model.Token {
t.Helper()
token := &model.Token{
UserId: userID,
Name: name,
Key: rawKey,
Status: common.TokenStatusEnabled,
CreatedTime: 1,
AccessedTime: 1,
ExpiredTime: -1,
RemainQuota: 100,
UnlimitedQuota: true,
Group: "default",
}
if err := db.Create(token).Error; err != nil {
t.Fatalf("failed to create token: %v", err)
}
return token
}
func newAuthenticatedContext(t *testing.T, method string, target string, body any, userID int) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
var requestBody *bytes.Reader
if body != nil {
payload, err := common.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
requestBody = bytes.NewReader(payload)
} else {
requestBody = bytes.NewReader(nil)
}
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(method, target, requestBody)
if body != nil {
ctx.Request.Header.Set("Content-Type", "application/json")
}
ctx.Set("id", userID)
return ctx, recorder
}
func decodeAPIResponse(t *testing.T, recorder *httptest.ResponseRecorder) tokenAPIResponse {
t.Helper()
var response tokenAPIResponse
if err := common.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to decode api response: %v", err)
}
return response
}
func TestGetAllTokensMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "list-token", "abcd1234efgh5678")
seedToken(t, db, 2, "other-user-token", "zzzz1234yyyy5678")
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?p=1&size=10", nil, 1)
GetAllTokens(ctx)
response := decodeAPIResponse(t, recorder)
if !response.Success {
t.Fatalf("expected success response, got message: %s", response.Message)
}
var page tokenPageResponse
if err := common.Unmarshal(response.Data, &page); err != nil {
t.Fatalf("failed to decode token page response: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("expected exactly one token, got %d", len(page.Items))
}
if page.Items[0].Key != token.GetMaskedKey() {
t.Fatalf("expected masked key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
}
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("list response leaked raw token key: %s", recorder.Body.String())
}
}
func TestSearchTokensMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "searchable-token", "ijkl1234mnop5678")
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=searchable-token&p=1&size=10", nil, 1)
SearchTokens(ctx)
response := decodeAPIResponse(t, recorder)
if !response.Success {
t.Fatalf("expected success response, got message: %s", response.Message)
}
var page tokenPageResponse
if err := common.Unmarshal(response.Data, &page); err != nil {
t.Fatalf("failed to decode search response: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("expected exactly one search result, got %d", len(page.Items))
}
if page.Items[0].Key != token.GetMaskedKey() {
t.Fatalf("expected masked search key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
}
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("search response leaked raw token key: %s", recorder.Body.String())
}
}
func TestGetTokenMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "detail-token", "qrst1234uvwx5678")
ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(token.Id), nil, 1)
ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
GetToken(ctx)
response := decodeAPIResponse(t, recorder)
if !response.Success {
t.Fatalf("expected success response, got message: %s", response.Message)
}
var detail tokenResponseItem
if err := common.Unmarshal(response.Data, &detail); err != nil {
t.Fatalf("failed to decode token detail response: %v", err)
}
if detail.Key != token.GetMaskedKey() {
t.Fatalf("expected masked detail key %q, got %q", token.GetMaskedKey(), detail.Key)
}
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("detail response leaked raw token key: %s", recorder.Body.String())
}
}
func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678")
body := map[string]any{
"id": token.Id,
"name": "updated-token",
"expired_time": -1,
"remain_quota": 100,
"unlimited_quota": true,
"model_limits_enabled": false,
"model_limits": "",
"group": "default",
"cross_group_retry": false,
}
ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1)
UpdateToken(ctx)
response := decodeAPIResponse(t, recorder)
if !response.Success {
t.Fatalf("expected success response, got message: %s", response.Message)
}
var detail tokenResponseItem
if err := common.Unmarshal(response.Data, &detail); err != nil {
t.Fatalf("failed to decode token update response: %v", err)
}
if detail.Key != token.GetMaskedKey() {
t.Fatalf("expected masked update key %q, got %q", token.GetMaskedKey(), detail.Key)
}
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("update response leaked raw token key: %s", recorder.Body.String())
}
}
func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "owned-token", "owner1234token5678")
authorizedCtx, authorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 1)
authorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
GetTokenKey(authorizedCtx)
authorizedResponse := decodeAPIResponse(t, authorizedRecorder)
if !authorizedResponse.Success {
t.Fatalf("expected authorized key fetch to succeed, got message: %s", authorizedResponse.Message)
}
var keyData tokenKeyResponse
if err := common.Unmarshal(authorizedResponse.Data, &keyData); err != nil {
t.Fatalf("failed to decode token key response: %v", err)
}
if keyData.Key != token.GetFullKey() {
t.Fatalf("expected full key %q, got %q", token.GetFullKey(), keyData.Key)
}
unauthorizedCtx, unauthorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 2)
unauthorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
GetTokenKey(unauthorizedCtx)
unauthorizedResponse := decodeAPIResponse(t, unauthorizedRecorder)
if unauthorizedResponse.Success {
t.Fatalf("expected unauthorized key fetch to fail")
}
if strings.Contains(unauthorizedRecorder.Body.String(), token.Key) {
t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String())
}
}

View File

@@ -43,6 +43,8 @@ services:
- redis
- postgres
# - mysql # Uncomment if using MySQL
networks:
- new-api-network
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
interval: 30s
@@ -53,6 +55,8 @@ services:
image: redis:latest
container_name: redis
restart: always
networks:
- new-api-network
postgres:
image: postgres:15
@@ -64,6 +68,8 @@ services:
POSTGRES_DB: new-api
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- new-api-network
# ports:
# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker
@@ -76,9 +82,15 @@ services:
# MYSQL_DATABASE: new-api
# volumes:
# - mysql_data:/var/lib/mysql
# networks:
# - new-api-network
# ports:
# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker
volumes:
pg_data:
# mysql_data:
networks:
new-api-network:
driver: bridge

View File

@@ -35,6 +35,27 @@ func (token *Token) Clean() {
token.Key = ""
}
func MaskTokenKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 4 {
return strings.Repeat("*", len(key))
}
if len(key) <= 8 {
return key[:2] + "****" + key[len(key)-2:]
}
return key[:4] + "**********" + key[len(key)-4:]
}
func (token *Token) GetFullKey() string {
return token.Key
}
func (token *Token) GetMaskedKey() string {
return MaskTokenKey(token.Key)
}
func (token *Token) GetIpLimits() []string {
// delete empty spaces
//split with \n
@@ -201,7 +222,7 @@ func ValidateUserToken(key string) (token *Token, err error) {
}
keyPrefix := key[:3]
keySuffix := key[len(key)-3:]
return token, errors.New(fmt.Sprintf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota))
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
}
return token, nil
}

View File

@@ -25,6 +25,7 @@ var ModelList = []string{
"claude-opus-4-6-high",
"claude-opus-4-6-medium",
"claude-opus-4-6-low",
"claude-sonnet-4-6",
}
var ChannelName = "claude"

View File

@@ -8,7 +8,8 @@ import (
var baseModelList = []string{
"gpt-5", "gpt-5-codex", "gpt-5-codex-mini",
"gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini",
"gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex",
"gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark",
"gpt-5.4",
}
var ModelList = withCompactModelSuffix(baseModelList)

View File

@@ -2,29 +2,34 @@ package gemini
var ModelList = []string{
// stable version
"gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-flash-8b",
"gemini-2.0-flash",
"gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash",
"gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", "gemini-2.0-flash-lite",
"gemini-2.5-flash-lite",
// latest version
"gemini-1.5-pro-latest", "gemini-1.5-flash-latest",
"gemini-flash-latest", "gemini-flash-lite-latest", "gemini-pro-latest",
"gemini-2.5-flash-native-audio-latest",
// preview version
"gemini-2.0-flash-lite-preview",
"gemini-3-pro-preview",
// gemini exp
"gemini-exp-1206",
// flash exp
"gemini-2.0-flash-exp",
// pro exp
"gemini-2.0-pro-exp",
// thinking exp
"gemini-2.0-flash-thinking-exp",
"gemini-2.5-pro-exp-03-25",
"gemini-2.5-pro-preview-03-25",
// imagen models
"imagen-3.0-generate-002",
"gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts",
"gemini-2.5-flash-image", "gemini-2.5-flash-lite-preview-09-2025",
"gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-3.1-pro-preview",
"gemini-3.1-pro-preview-customtools", "gemini-3.1-flash-lite-preview",
"gemini-3-pro-image-preview", "nano-banana-pro-preview",
"gemini-3.1-flash-image-preview", "gemini-robotics-er-1.5-preview",
"gemini-2.5-computer-use-preview-10-2025", "deep-research-pro-preview-12-2025",
"gemini-2.5-flash-native-audio-preview-09-2025", "gemini-2.5-flash-native-audio-preview-12-2025",
// gemma models
"gemma-3-1b-it", "gemma-3-4b-it", "gemma-3-12b-it",
"gemma-3-27b-it", "gemma-3n-e4b-it", "gemma-3n-e2b-it",
// embedding models
"gemini-embedding-exp-03-07",
"text-embedding-004",
"embedding-001",
"gemini-embedding-001", "gemini-embedding-2-preview",
// imagen models
"imagen-4.0-generate-001", "imagen-4.0-ultra-generate-001",
"imagen-4.0-fast-generate-001",
// veo models
"veo-2.0-generate-001", "veo-3.0-generate-001", "veo-3.0-fast-generate-001",
"veo-3.1-generate-preview", "veo-3.1-fast-generate-preview",
// other models
"aqa",
}
var SafetySettingList = []string{

View File

@@ -15,8 +15,10 @@ var ModelList = []string{
"speech-01-hd",
"speech-01-turbo",
"MiniMax-M2.1",
"MiniMax-M2.1-lightning",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
}
var ChannelName = "minimax"

View File

@@ -1,9 +1,11 @@
package moonshot
var ModelList = []string{
"moonshot-v1-8k",
"moonshot-v1-32k",
"moonshot-v1-128k",
"kimi-k2.5",
"kimi-k2-0905-preview",
"kimi-k2-turbo-preview",
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
}
var ChannelName = "moonshot"

View File

@@ -225,8 +225,12 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *
}
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
header.Set("HTTP-Referer", "https://www.newapi.ai")
header.Set("X-Title", "New API")
if header.Get("HTTP-Referer") == "" {
header.Set("HTTP-Referer", "https://www.newapi.ai")
}
if header.Get("X-OpenRouter-Title") == "" {
header.Set("X-OpenRouter-Title", "New API")
}
}
return nil
}

View File

@@ -3,14 +3,19 @@ package openai
var ModelList = []string{
"gpt-3.5-turbo", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-0125",
"gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k-0613",
"gpt-3.5-turbo-instruct",
"gpt-3.5-turbo-instruct", "gpt-3.5-turbo-instruct-0914",
"gpt-4", "gpt-4-0613", "gpt-4-1106-preview", "gpt-4-0125-preview",
"gpt-4-32k", "gpt-4-32k-0613",
"gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09",
"gpt-4-vision-preview",
"chatgpt-4o-latest",
"gpt-4o", "gpt-4o-2024-05-13", "gpt-4o-2024-08-06", "gpt-4o-2024-11-20",
"gpt-4o-transcribe", "gpt-4o-transcribe-diarize",
"gpt-4o-search-preview", "gpt-4o-search-preview-2025-03-11",
"gpt-4o-mini", "gpt-4o-mini-2024-07-18",
"gpt-4o-mini-transcribe", "gpt-4o-mini-transcribe-2025-03-20", "gpt-4o-mini-transcribe-2025-12-15",
"gpt-4o-mini-tts", "gpt-4o-mini-tts-2025-03-20", "gpt-4o-mini-tts-2025-12-15",
"gpt-4o-mini-search-preview", "gpt-4o-mini-search-preview-2025-03-11",
"gpt-4.5-preview", "gpt-4.5-preview-2025-02-27",
"gpt-4.1", "gpt-4.1-2025-04-14",
"gpt-4.1-mini", "gpt-4.1-mini-2025-04-14",
@@ -31,17 +36,41 @@ var ModelList = []string{
"gpt-5", "gpt-5-2025-08-07", "gpt-5-chat-latest",
"gpt-5-mini", "gpt-5-mini-2025-08-07",
"gpt-5-nano", "gpt-5-nano-2025-08-07",
"gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01",
"gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01", "gpt-4o-realtime-preview-2024-12-17",
"gpt-5-codex",
"gpt-5-pro", "gpt-5-pro-2025-10-06",
"gpt-5-search-api", "gpt-5-search-api-2025-10-14",
"gpt-5.1", "gpt-5.1-2025-11-13", "gpt-5.1-chat-latest",
"gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max",
"gpt-5.2", "gpt-5.2-2025-12-11", "gpt-5.2-chat-latest",
"gpt-5.2-pro", "gpt-5.2-pro-2025-12-11",
"gpt-5.2-codex",
"gpt-5.3-chat-latest",
"gpt-5.3-codex",
"gpt-5.4", "gpt-5.4-2026-03-05",
"gpt-5.4-pro", "gpt-5.4-pro-2026-03-05",
"gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01", "gpt-4o-audio-preview-2024-12-17", "gpt-4o-audio-preview-2025-06-03",
"gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01", "gpt-4o-realtime-preview-2024-12-17", "gpt-4o-realtime-preview-2025-06-03",
"gpt-4o-mini-realtime-preview", "gpt-4o-mini-realtime-preview-2024-12-17",
"gpt-4o-mini-audio-preview", "gpt-4o-mini-audio-preview-2024-12-17",
"gpt-audio", "gpt-audio-2025-08-28",
"gpt-audio-mini", "gpt-audio-mini-2025-10-06", "gpt-audio-mini-2025-12-15",
"gpt-audio-1.5",
"gpt-realtime", "gpt-realtime-2025-08-28",
"gpt-realtime-mini", "gpt-realtime-mini-2025-10-06", "gpt-realtime-mini-2025-12-15",
"gpt-realtime-1.5",
"text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large",
"text-curie-001", "text-babbage-001", "text-ada-001",
"text-moderation-latest", "text-moderation-stable",
"omni-moderation-latest", "omni-moderation-2024-09-26",
"text-davinci-edit-001",
"davinci-002", "babbage-002",
"dall-e-3", "gpt-image-1",
"dall-e-2", "dall-e-3",
"gpt-image-1", "gpt-image-1-mini", "gpt-image-1.5",
"chatgpt-image-latest",
"whisper-1",
"tts-1", "tts-1-1106", "tts-1-hd", "tts-1-hd-1106",
"computer-use-preview", "computer-use-preview-2025-03-11",
"sora-2", "sora-2-pro",
}
var ChannelName = "openai"

View File

@@ -405,5 +405,12 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro
Code: fmt.Sprintf("%d", klingResp.Code),
}
}
// https://app.klingai.com/cn/dev/document-api/apiReference/model/textToVideo
if data := klingResp.Data; data.TaskStatus == "failed" {
openAIVideo.Error = &dto.OpenAIVideoError{
Message: data.TaskStatusMsg,
}
}
return common.Marshal(openAIVideo)
}

View File

@@ -1,7 +1,7 @@
package zhipu_4v
var ModelList = []string{
"glm-4", "glm-4v", "glm-3-turbo", "glm-4-alltools", "glm-4-plus", "glm-4-0520", "glm-4-air", "glm-4-airx", "glm-4-long", "glm-4-flash", "glm-4v-plus", "glm-4.6",
"glm-4", "glm-4v", "glm-3-turbo", "glm-4-alltools", "glm-4-plus", "glm-4-0520", "glm-4-air", "glm-4-airx", "glm-4-long", "glm-4-flash", "glm-4v-plus", "glm-4.6", "glm-4.6v", "glm-4.7", "glm-4.7-flash", "glm-5",
}
var ChannelName = "zhipu_4v"

View File

@@ -847,24 +847,30 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN
return "", false, fmt.Errorf("header value mapping cannot be empty")
}
sourceValue, exists := getHeaderValueFromContext(context, headerName)
if !exists {
return "", false, nil
appendTokens, err := parseHeaderAppendTokens(mapping)
if err != nil {
return "", false, err
}
sourceTokens := splitHeaderListValue(sourceValue)
if len(sourceTokens) == 0 {
return "", false, nil
keepOnlyDeclared := parseHeaderKeepOnlyDeclared(mapping)
sourceValue, exists := getHeaderValueFromContext(context, headerName)
sourceTokens := make([]string, 0)
if exists {
sourceTokens = splitHeaderListValue(sourceValue)
}
wildcardValue, hasWildcard := mapping["*"]
resultTokens := make([]string, 0, len(sourceTokens))
resultTokens := make([]string, 0, len(sourceTokens)+len(appendTokens))
for _, token := range sourceTokens {
replacementRaw, hasReplacement := mapping[token]
if !hasReplacement && hasWildcard {
if !hasReplacement && hasWildcard && !keepOnlyDeclared {
replacementRaw = wildcardValue
hasReplacement = true
}
if !hasReplacement {
if keepOnlyDeclared {
continue
}
resultTokens = append(resultTokens, token)
continue
}
@@ -875,6 +881,7 @@ func resolveHeaderOverrideValueByMapping(context map[string]interface{}, headerN
resultTokens = append(resultTokens, replacementTokens...)
}
resultTokens = append(resultTokens, appendTokens...)
resultTokens = lo.Uniq(resultTokens)
if len(resultTokens) == 0 {
return "", false, nil
@@ -882,6 +889,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:

View File

@@ -1653,6 +1653,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{}{

View File

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

View File

@@ -214,7 +214,7 @@ func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) {
relayMjRouter.POST("/submit/blend", controller.RelayMidjourney)
relayMjRouter.POST("/submit/edits", controller.RelayMidjourney)
relayMjRouter.POST("/submit/video", controller.RelayMidjourney)
relayMjRouter.POST("/notify", controller.RelayMidjourney)
//relayMjRouter.POST("/notify", controller.RelayMidjourney)
relayMjRouter.GET("/task/:id/fetch", controller.RelayMidjourney)
relayMjRouter.GET("/task/:id/image-seed", controller.RelayMidjourney)
relayMjRouter.POST("/task/list-by-condition", controller.RelayMidjourney)

View File

@@ -2,6 +2,7 @@ package model_setting
import (
"net/http"
"strings"
"github.com/QuantumNous/new-api/setting/config"
)
@@ -50,23 +51,36 @@ func GetClaudeSettings() *ClaudeSettings {
func (c *ClaudeSettings) WriteHeaders(originModel string, httpHeader *http.Header) {
if headers, ok := c.HeadersSettings[originModel]; ok {
for headerKey, headerValues := range headers {
// get existing values for this header key
existingValues := httpHeader.Values(headerKey)
existingValuesMap := make(map[string]bool)
for _, v := range existingValues {
existingValuesMap[v] = true
}
// add only values that don't already exist
for _, headerValue := range headerValues {
if !existingValuesMap[headerValue] {
httpHeader.Add(headerKey, headerValue)
}
mergedValues := normalizeHeaderListValues(
append(append([]string(nil), httpHeader.Values(headerKey)...), headerValues...),
)
if len(mergedValues) == 0 {
continue
}
httpHeader.Set(headerKey, strings.Join(mergedValues, ","))
}
}
}
func normalizeHeaderListValues(values []string) []string {
normalizedValues := make([]string, 0, len(values))
seenValues := make(map[string]struct{}, len(values))
for _, value := range values {
for _, item := range strings.Split(value, ",") {
normalizedItem := strings.TrimSpace(item)
if normalizedItem == "" {
continue
}
if _, exists := seenValues[normalizedItem]; exists {
continue
}
seenValues[normalizedItem] = struct{}{}
normalizedValues = append(normalizedValues, normalizedItem)
}
}
return normalizedValues
}
func (c *ClaudeSettings) GetDefaultMaxTokens(model string) int {
if maxTokens, ok := c.DefaultMaxTokens[model]; ok {
return maxTokens

View File

@@ -34,7 +34,7 @@ import {
TextArea,
Typography,
} from '@douyinfe/semi-ui';
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
import { IconDelete, IconMenu, IconPlus } from '@douyinfe/semi-icons';
import { copy, showError, showSuccess, verifyJSON } from '../../../../helpers';
import {
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
@@ -163,7 +163,7 @@ const MODE_DESCRIPTIONS = {
prune_objects: '按条件清理对象中的子项',
pass_headers: '把指定请求头透传到上游请求',
sync_fields: '在一个字段有值、另一个缺失时自动补齐',
set_header: '设置运行期请求头(支持整值覆盖,或用 JSON 映射按逗号 token 替换/删除)',
set_header: '设置运行期请求头:可直接覆盖整条值,也可对逗号分隔的 token 做删除、替换、追加或白名单保留',
delete_header: '删除运行期请求头',
copy_header: '复制请求头',
move_header: '移动请求头',
@@ -230,17 +230,29 @@ const getModeValueLabel = (mode) => {
return '值(支持 JSON 或普通文本)';
};
const HEADER_VALUE_JSONC_EXAMPLE = `{
// 置空:删除 Bedrock 不支持的 beta特性
"files-api-2025-04-14": null,
// 替换:把旧特性改成兼容特性
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
// 追加:在末尾补一个需要的特性
"$append": ["context-1m-2025-08-07"]
}`;
const getModeValuePlaceholder = (mode) => {
if (mode === 'set_header') {
return [
'String example:',
'纯字符串(整条覆盖):',
'Bearer sk-xxx',
'',
'JSON map example:',
'{"advanced-tool-use-2025-11-20": null, "computer-use-2025-01-24": "computer-use-2025-01-24"}',
'',
'JSON map wildcard:',
'{"*": null, "computer-use-2025-11-24": "computer-use-2025-11-24"}',
'或使用 JSON 规则:',
'{',
' "files-api-2025-04-14": null,',
' "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",',
' "$append": ["context-1m-2025-08-07"]',
'}',
].join('\n');
}
if (mode === 'pass_headers') return 'Authorization, X-Request-Id';
@@ -258,11 +270,6 @@ const getModeValuePlaceholder = (mode) => {
return '0.7';
};
const getModeValueHelp = (mode) => {
if (mode !== 'set_header') return '';
return '字符串整条请求头直接覆盖。JSON 映射:按逗号分隔 token 逐项处理null 表示删除string/array 表示替换,* 表示兜底规则。';
};
const SYNC_TARGET_TYPE_OPTIONS = [
{ label: '请求体字段', value: 'json' },
{ label: '请求头字段', value: 'header' },
@@ -369,6 +376,7 @@ const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = {
'tool-search-tool-2025-10-19': 'tool-search-tool-2025-10-19',
'web-fetch-2025-09-10': null,
'web-search-2025-03-05': null,
'oauth-2025-04-20': null
},
},
{
@@ -800,6 +808,38 @@ const normalizeOperation = (operation = {}) => ({
const createDefaultOperation = () => normalizeOperation({ mode: 'set' });
const reorderOperations = (
sourceOperations = [],
sourceId,
targetId,
position = 'before',
) => {
if (!sourceId || !targetId || sourceId === targetId) {
return sourceOperations;
}
const sourceIndex = sourceOperations.findIndex((item) => item.id === sourceId);
if (sourceIndex < 0) {
return sourceOperations;
}
const nextOperations = [...sourceOperations];
const [moved] = nextOperations.splice(sourceIndex, 1);
let insertIndex = nextOperations.findIndex((item) => item.id === targetId);
if (insertIndex < 0) {
return sourceOperations;
}
if (position === 'after') {
insertIndex += 1;
}
nextOperations.splice(insertIndex, 0, moved);
return nextOperations;
};
const getOperationSummary = (operation = {}, index = 0) => {
const mode = operation.mode || 'set';
const modeLabel = OPERATION_MODE_LABEL_MAP[mode] || mode;
@@ -1037,8 +1077,12 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
const [operationSearch, setOperationSearch] = useState('');
const [selectedOperationId, setSelectedOperationId] = useState('');
const [expandedConditionMap, setExpandedConditionMap] = useState({});
const [draggedOperationId, setDraggedOperationId] = useState('');
const [dragOverOperationId, setDragOverOperationId] = useState('');
const [dragOverPosition, setDragOverPosition] = useState('before');
const [templateGroupKey, setTemplateGroupKey] = useState('basic');
const [templatePresetKey, setTemplatePresetKey] = useState('operations_default');
const [headerValueExampleVisible, setHeaderValueExampleVisible] = useState(false);
const [fieldGuideVisible, setFieldGuideVisible] = useState(false);
const [fieldGuideTarget, setFieldGuideTarget] = useState('path');
const [fieldGuideKeyword, setFieldGuideKeyword] = useState('');
@@ -1055,6 +1099,9 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
setOperationSearch('');
setSelectedOperationId(nextState.operations[0]?.id || '');
setExpandedConditionMap({});
setDraggedOperationId('');
setDragOverOperationId('');
setDragOverPosition('before');
if (nextState.visualMode === 'legacy') {
setTemplateGroupKey('basic');
setTemplatePresetKey('legacy_default');
@@ -1062,6 +1109,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
setTemplateGroupKey('basic');
setTemplatePresetKey('operations_default');
}
setHeaderValueExampleVisible(false);
setFieldGuideVisible(false);
setFieldGuideTarget('path');
setFieldGuideKeyword('');
@@ -1583,6 +1631,67 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
setSelectedOperationId(created.id);
};
const resetOperationDragState = useCallback(() => {
setDraggedOperationId('');
setDragOverOperationId('');
setDragOverPosition('before');
}, []);
const moveOperation = useCallback(
(sourceId, targetId, position = 'before') => {
if (!sourceId || !targetId || sourceId === targetId) {
return;
}
setOperations((prev) =>
reorderOperations(prev, sourceId, targetId, position),
);
setSelectedOperationId(sourceId);
},
[],
);
const handleOperationDragStart = useCallback((event, operationId) => {
setDraggedOperationId(operationId);
setSelectedOperationId(operationId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', operationId);
}, []);
const handleOperationDragOver = useCallback(
(event, operationId) => {
event.preventDefault();
if (!draggedOperationId || draggedOperationId === operationId) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const position =
event.clientY - rect.top > rect.height / 2 ? 'after' : 'before';
setDragOverOperationId(operationId);
setDragOverPosition(position);
event.dataTransfer.dropEffect = 'move';
},
[draggedOperationId],
);
const handleOperationDrop = useCallback(
(event, operationId) => {
event.preventDefault();
const sourceId =
draggedOperationId || event.dataTransfer.getData('text/plain');
const position =
dragOverOperationId === operationId ? dragOverPosition : 'before';
moveOperation(sourceId, operationId, position);
resetOperationDragState();
},
[
dragOverOperationId,
dragOverPosition,
draggedOperationId,
moveOperation,
resetOperationDragState,
],
);
const duplicateOperation = (operationId) => {
let insertedId = '';
setOperations((prev) => {
@@ -1941,14 +2050,31 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
);
const isActive =
operation.id === selectedOperationId;
const isDragging =
operation.id === draggedOperationId;
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId &&
draggedOperationId !== operation.id;
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onDragStart={(event) =>
handleOperationDragStart(event, operation.id)
}
onDragOver={(event) =>
handleOperationDragOver(event, operation.id)
}
onDrop={(event) =>
handleOperationDrop(event, operation.id)
}
onDragEnd={resetOperationDragState}
onKeyDown={(event) => {
if (
event.key === 'Enter' ||
@@ -1966,35 +2092,53 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
border: isActive
? '1px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
opacity: isDragging ? 0.6 : 1,
boxShadow: isDropTarget
? dragOverPosition === 'after'
? 'inset 0 -3px 0 var(--semi-color-primary)'
: 'inset 0 3px 0 var(--semi-color-primary)'
: 'none',
}}
>
<div className='flex items-start justify-between gap-2'>
<div>
<Text strong>{`#${index + 1}`}</Text>
<Text
type='tertiary'
size='small'
className='block mt-1'
<div className='flex items-start gap-2 min-w-0'>
<div
className='flex-shrink-0'
style={{
color: 'var(--semi-color-text-2)',
cursor: operations.length > 1 ? 'grab' : 'default',
marginTop: 1,
}}
>
{getOperationSummary(operation, index)}
</Text>
{String(operation.description || '').trim() ? (
<IconMenu />
</div>
<div className='min-w-0'>
<Text strong>{`#${index + 1}`}</Text>
<Text
type='tertiary'
size='small'
className='block mt-1'
style={{
lineHeight: 1.5,
wordBreak: 'break-word',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{operation.description}
{getOperationSummary(operation, index)}
</Text>
) : null}
{String(operation.description || '').trim() ? (
<Text
type='tertiary'
size='small'
className='block mt-1'
style={{
lineHeight: 1.5,
wordBreak: 'break-word',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{operation.description}
</Text>
) : null}
</div>
</div>
<Tag size='small' color='grey'>
{(operation.conditions || []).length}
@@ -2688,15 +2832,35 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
{t(getModeValueLabel(mode))}
</Text>
{mode === 'set_header' ? (
<Button
size='small'
type='tertiary'
onClick={formatSelectedOperationValueAsJson}
>
{t('格式化 JSON')}
</Button>
<Space spacing={6}>
<Button
size='small'
type='tertiary'
onClick={() =>
setHeaderValueExampleVisible(true)
}
>
{t('查看 JSON 示例')}
</Button>
<Button
size='small'
type='tertiary'
onClick={formatSelectedOperationValueAsJson}
>
{t('格式化 JSON')}
</Button>
</Space>
) : null}
</div>
{mode === 'set_header' ? (
<Text
type='tertiary'
size='small'
className='mt-1 mb-2 block'
>
{t('纯字符串会直接覆盖整条请求头,或者点击“查看 JSON 示例”按 token 规则处理。')}
</Text>
) : null}
<TextArea
value={selectedOperation.value_text}
autosize={{ minRows: 1, maxRows: 4 }}
@@ -2707,11 +2871,6 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
})
}
/>
{getModeValueHelp(mode) ? (
<Text type='tertiary' size='small'>
{t(getModeValueHelp(mode))}
</Text>
) : null}
</div>
)
) : null}
@@ -3167,6 +3326,27 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
</Space>
</Modal>
<Modal
title={t('anthropic-beta JSON 示例')}
visible={headerValueExampleVisible}
width={760}
footer={null}
onCancel={() => setHeaderValueExampleVisible(false)}
bodyStyle={{ padding: 16, paddingBottom: 24 }}
>
<Space vertical align='start' spacing={12} style={{ width: '100%' }}>
<Text type='tertiary' size='small'>
{t('下面是带注释的示例,仅用于参考;实际保存时请删除注释。')}
</Text>
<TextArea
value={HEADER_VALUE_JSONC_EXAMPLE}
readOnly
autosize={{ minRows: 16, maxRows: 20 }}
style={{ marginBottom: 8 }}
/>
</Space>
</Modal>
<Modal
title={null}
visible={fieldGuideVisible}

View File

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

View File

@@ -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('可用模型'),

View File

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

View File

@@ -58,6 +58,7 @@ function TokensPage() {
t: (k) => k,
selectedModel: '',
prefillKey: '',
fetchTokenKey: async () => '',
});
const [modelOptions, setModelOptions] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
@@ -74,6 +75,7 @@ function TokensPage() {
t: tokensData.t,
selectedModel,
prefillKey,
fetchTokenKey: tokensData.fetchTokenKey,
};
}, [
tokensData.tokens,
@@ -81,6 +83,7 @@ function TokensPage() {
tokensData.t,
selectedModel,
prefillKey,
tokensData.fetchTokenKey,
]);
const loadModels = async () => {
@@ -198,13 +201,14 @@ function TokensPage() {
openCCSwitchModalRef.current = openCCSwitchModal;
// Prefill to Fluent handler
const handlePrefillToFluent = () => {
const handlePrefillToFluent = async () => {
const {
tokens,
selectedKeys,
t,
selectedModel: chosenModel,
prefillKey: overrideKey,
fetchTokenKey,
} = latestRef.current;
const container = document.getElementById('fluent-new-api-container');
if (!container) {
@@ -241,7 +245,11 @@ function TokensPage() {
Toast.warning(t('没有可用令牌用于填充'));
return;
}
apiKeyToUse = 'sk-' + token.key;
try {
apiKeyToUse = 'sk-' + (await fetchTokenKey(token));
} catch (_) {
return;
}
}
const payload = {
@@ -351,7 +359,6 @@ function TokensPage() {
setShowEdit,
batchCopyTokens,
batchDeleteTokens,
copyText,
// Filters state
formInitValues,
@@ -401,7 +408,6 @@ function TokensPage() {
setShowEdit={setShowEdit}
batchCopyTokens={batchCopyTokens}
batchDeleteTokens={batchDeleteTokens}
copyText={copyText}
t={t}
/>

View File

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

View File

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

View File

@@ -73,7 +73,7 @@ const ColumnSelectorModal = ({
<RadioGroup
type='button'
value={billingDisplayMode}
onChange={(event) => setBillingDisplayMode(event.target.value)}
onChange={(value) => setBillingDisplayMode(value)}
>
<Radio value='price'>
{isTokensDisplay ? t('价格模式') : t('价格模式(默认)')}

View File

@@ -1183,7 +1183,7 @@ function getEffectiveRatio(groupRatio, user_group_ratio) {
const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
const ratioLabel = useUserGroupRatio
? i18next.t('专属倍率')
: i18next.t('分组倍率');
: i18next.t('分组');
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
return {

View File

@@ -20,8 +20,22 @@ For commercial licensing, please contact support@quantumnous.com
import { API } from './api';
/**
* 获取可用的token keys
* @returns {Promise<string[]>} 返回active状态的token key数组
* 按需获取单个令牌的真实 key
* @param {number|string} tokenId
* @returns {Promise<string>} 返回不带 sk- 前缀的真实 token key
*/
export async function fetchTokenKey(tokenId) {
const response = await API.post(`/api/token/${tokenId}/key`);
const { success, data, message } = response.data || {};
if (!success || !data?.key) {
throw new Error(message || 'Failed to fetch token key');
}
return data.key;
}
/**
* 获取可用的 token keys
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
*/
export async function fetchTokenKeys() {
try {
@@ -31,7 +45,12 @@ export async function fetchTokenKeys() {
const tokenItems = Array.isArray(data) ? data : data.items || [];
const activeTokens = tokenItems.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
const keyResults = await Promise.allSettled(
activeTokens.map((token) => fetchTokenKey(token.id)),
);
return keyResults
.filter((result) => result.status === 'fulfilled' && result.value)
.map((result) => result.value);
} catch (error) {
console.error('Error fetching token keys:', error);
return [];

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui';
import {
@@ -29,6 +29,7 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
const { t } = useTranslation();
@@ -54,6 +55,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
// UI state
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
const [showKeys, setShowKeys] = useState({});
const [resolvedTokenKeys, setResolvedTokenKeys] = useState({});
const [loadingTokenKeys, setLoadingTokenKeys] = useState({});
const keyRequestsRef = useRef({});
// Form state
const [formApi, setFormApi] = useState(null);
@@ -87,6 +91,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
setTokenCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
setShowKeys({});
};
// Load tokens function
@@ -122,14 +127,86 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
}
};
const fetchTokenKey = async (tokenOrId, options = {}) => {
const { suppressError = false } = options;
const tokenId =
typeof tokenOrId === 'object' ? tokenOrId?.id : Number(tokenOrId);
if (!tokenId) {
const error = new Error(t('令牌不存在'));
if (!suppressError) {
showError(error.message);
}
throw error;
}
if (resolvedTokenKeys[tokenId]) {
return resolvedTokenKeys[tokenId];
}
if (keyRequestsRef.current[tokenId]) {
return keyRequestsRef.current[tokenId];
}
const request = (async () => {
setLoadingTokenKeys((prev) => ({ ...prev, [tokenId]: true }));
try {
const fullKey = await fetchTokenKeyById(tokenId);
setResolvedTokenKeys((prev) => ({ ...prev, [tokenId]: fullKey }));
return fullKey;
} catch (error) {
const normalizedError = new Error(
error?.message || t('获取令牌密钥失败'),
);
if (!suppressError) {
showError(normalizedError.message);
}
throw normalizedError;
} finally {
delete keyRequestsRef.current[tokenId];
setLoadingTokenKeys((prev) => {
const next = { ...prev };
delete next[tokenId];
return next;
});
}
})();
keyRequestsRef.current[tokenId] = request;
return request;
};
const toggleTokenVisibility = async (record) => {
const tokenId = record?.id;
if (!tokenId) {
return;
}
if (showKeys[tokenId]) {
setShowKeys((prev) => ({ ...prev, [tokenId]: false }));
return;
}
const fullKey = await fetchTokenKey(record);
if (fullKey) {
setShowKeys((prev) => ({ ...prev, [tokenId]: true }));
}
};
const copyTokenKey = async (record) => {
const fullKey = await fetchTokenKey(record);
await copyText(`sk-${fullKey}`);
};
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
const fullKey = await fetchTokenKey(record);
if (url && url.startsWith('ccswitch')) {
openCCSwitchModal(record.key);
openCCSwitchModal(fullKey);
return;
}
if (url && url.startsWith('fluent')) {
openFluentNotification(record.key);
openFluentNotification(fullKey);
return;
}
let status = localStorage.getItem('status');
@@ -145,7 +222,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
let cherryConfig = {
id: 'new-api',
baseUrl: serverAddress,
apiKey: 'sk-' + record.key,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(cherryConfig)),
@@ -155,7 +232,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
let aionuiConfig = {
platform: 'new-api',
baseUrl: serverAddress,
apiKey: 'sk-' + record.key,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(aionuiConfig)),
@@ -164,7 +241,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
} else {
let encodedServerAddress = encodeURIComponent(serverAddress);
url = url.replaceAll('{address}', encodedServerAddress);
url = url.replaceAll('{key}', 'sk-' + record.key);
url = url.replaceAll('{key}', `sk-${fullKey}`);
}
window.open(url, '_blank');
@@ -314,48 +391,28 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
};
// Batch copy tokens
const batchCopyTokens = (copyType) => {
const batchCopyTokens = async (copyType) => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
Modal.info({
title: t('复制令牌'),
icon: null,
content: t('请选择你的复制方式'),
footer: (
<div className='flex gap-2'>
<button
className='px-3 py-1 bg-gray-200 rounded'
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content +=
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
Modal.destroyAll();
}}
>
{t('名称+密钥')}
</button>
<button
className='px-3 py-1 bg-blue-500 text-white rounded'
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content += 'sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
Modal.destroyAll();
}}
>
{t('仅密钥')}
</button>
</div>
),
});
try {
const keys = await Promise.all(
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
);
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
const fullKey = keys[i];
if (copyType === 'name+key') {
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
} else {
content += `sk-${fullKey}\n`;
}
}
await copyText(content);
} catch (error) {
showError(error?.message || t('复制令牌失败'));
}
};
// Initialize data
@@ -392,6 +449,8 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
setCompactMode,
showKeys,
setShowKeys,
resolvedTokenKeys,
loadingTokenKeys,
// Form state
formApi,
@@ -403,6 +462,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
loadTokens,
refresh,
copyText,
fetchTokenKey,
toggleTokenVisibility,
copyTokenKey,
onOpenLink,
manageToken,
searchTokens,

View File

@@ -85,6 +85,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude thinking adaptation BudgetTokens = MaxTokens * BudgetTokens percentage",
"Claude设置": "Claude settings",
"Claude请求头覆盖": "Claude request header override",
"Claude请求头追加": "Claude request header append",
"Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。": "Claude appends these values on top of existing request headers. Existing headers are not overwritten, and duplicate values are ignored automatically.",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"Codex 授权": "",
@@ -1781,6 +1783,9 @@
"格式化 JSON": "Format JSON",
"格式正确": "Format Correct",
"格式示例:": "Format example:",
"前:": "Before:",
"配置:": "Config:",
"后:": "After:",
"格式错误": "Format Error",
"检查更新": "Check for updates",
"检测到 FluentRead流畅阅读": "FluentRead (smooth reading) detected",

View File

@@ -86,6 +86,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Adaptation de la pensée Claude BudgetTokens = MaxTokens * BudgetTokens pourcentage",
"Claude设置": "Paramètres Claude",
"Claude请求头覆盖": "Remplacement de l'en-tête de la requête Claude",
"Claude请求头追加": "Ajout des en-tetes de requete Claude",
"Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。": "Claude ajoute ces valeurs aux en-tetes de requete existants. Les en-tetes existants ne sont pas remplaces et les valeurs en double sont ignorees automatiquement.",
"Client ID": "ID client",
"Client Secret": "Secret client",
"Codex 授权": "",
@@ -1765,6 +1767,9 @@
"格式化 JSON": "Formater le JSON",
"格式正确": "Format valide",
"格式示例:": "Exemple de format :",
"前:": "Avant :",
"配置:": "Configuration :",
"后:": "Apres :",
"格式错误": "Format invalide",
"检查更新": "Vérifier les mises à jour",
"检测到 FluentRead流畅阅读": "FluentRead détecté",

View File

@@ -82,6 +82,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude思考モードBudgetTokens = MaxTokens * BudgetTokensの割合",
"Claude设置": "Claude設定",
"Claude请求头覆盖": "Claudeリクエストヘッダーの上書き",
"Claude请求头追加": "Claudeリクエストヘッダーの追加",
"Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。": "Claude は既存のリクエストヘッダーにこれらの値を追加します。既存の同名ヘッダーは上書きされず、重複した値は自動的に無視されます。",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"Codex 授权": "",
@@ -1748,6 +1750,9 @@
"格式化 JSON": "JSON を整形",
"格式正确": "有効な形式",
"格式示例:": "フォーマット例:",
"前:": "前:",
"配置:": "設定:",
"后:": "後:",
"格式错误": "無効な形式",
"检查更新": "更新を確認",
"检测到 FluentRead流畅阅读": "FluentReadが検出されました",

View File

@@ -89,6 +89,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Адаптация мышления Claude BudgetTokens = MaxTokens * процент BudgetTokens",
"Claude设置": "Настройки Claude",
"Claude请求头覆盖": "Переопределение заголовков запроса Claude",
"Claude请求头追加": "Добавление заголовков запроса Claude",
"Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。": "Claude добавляет эти значения поверх существующих заголовков запроса. Уже существующие заголовки не перезаписываются, а дублирующиеся значения автоматически игнорируются.",
"Client ID": "ID клиента",
"Client Secret": "Секрет клиента",
"Codex 授权": "",
@@ -1777,6 +1779,9 @@
"格式化 JSON": "Форматировать JSON",
"格式正确": "Действительный формат",
"格式示例:": "Пример формата: ",
"前:": "До:",
"配置:": "Конфиг:",
"后:": "После:",
"格式错误": "Недействительный формат",
"检查更新": "Проверить обновления",
"检测到 FluentRead流畅阅读": "Обнаружен FluentRead (плавное чтение)",

View File

@@ -82,6 +82,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Thích ứng tư duy Claude BudgetTokens = MaxTokens * Tỷ lệ phần trăm BudgetTokens",
"Claude设置": "Cài đặt Claude",
"Claude请求头覆盖": "Ghi đè tiêu đề yêu cầu Claude",
"Claude请求头追加": "Thêm tiêu đề yêu cầu Claude",
"Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。": "Claude sẽ thêm các giá trị này vào các tiêu đề yêu cầu hiện có. Các tiêu đề cùng tên sẽ không bị ghi đè và các giá trị trùng lặp sẽ tự động bị bỏ qua.",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"Codex 授权": "",
@@ -1749,6 +1751,9 @@
"格式化 JSON": "Định dạng JSON",
"格式正确": "Định dạng hợp lệ",
"格式示例:": "Ví dụ định dạng:",
"前:": "Trước:",
"配置:": "Cấu hình:",
"后:": "Sau:",
"格式错误": "Định dạng không hợp lệ",
"检查更新": "Kiểm tra cập nhật",
"检测到 FluentRead流畅阅读": "Đã phát hiện FluentRead (đọc trôi chảy)",

View File

@@ -69,6 +69,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比",
"Claude设置": "Claude设置",
"Claude请求头覆盖": "Claude请求头覆盖",
"Claude请求头追加": "Claude请求头追加",
"Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。": "Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"common.changeLanguage": "common.changeLanguage",
@@ -1415,6 +1417,7 @@
"格式化": "格式化",
"格式正确": "格式正确",
"格式示例:": "格式示例:",
"前:": "前:",
"格式错误": "格式错误",
"检查更新": "检查更新",
"检测到 FluentRead流畅阅读": "检测到 FluentRead流畅阅读",
@@ -2830,7 +2833,9 @@
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。",
"补全价格已锁定": "补全价格已锁定",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。",
"后:": "后:",
"这些价格都是可选项,不填也可以。": "这些价格都是可选项,不填也可以。",
"配置:": "配置:",
"请先开启并填写音频输入价格。": "请先开启并填写音频输入价格。",
"输入模型名称,例如 gpt-4.1": "输入模型名称,例如 gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。",

View File

@@ -69,6 +69,8 @@
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude思考相容 BudgetTokens = MaxTokens * BudgetTokens 百分比",
"Claude设置": "Claude設定",
"Claude请求头覆盖": "Claude請求頭覆蓋",
"Claude请求头追加": "Claude請求頭追加",
"Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。": "Claude會在原有請求頭基礎上追加這些值不會覆蓋已有同名請求頭重複值會自動忽略。",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"common.changeLanguage": "common.changeLanguage",
@@ -1419,6 +1421,9 @@
"格式化": "格式化",
"格式正确": "格式正確",
"格式示例:": "格式示例:",
"前:": "前:",
"配置:": "配置:",
"后:": "後:",
"格式错误": "格式錯誤",
"检查更新": "檢查更新",
"检测到 FluentRead流畅阅读": "檢測到 FluentRead流暢閱讀",

View File

@@ -39,6 +39,16 @@ const CLAUDE_HEADER = {
},
};
const CLAUDE_HEADER_APPEND_CONFIG = {
'claude-3-7-sonnet-20250219-thinking': {
'anthropic-beta': ['token-efficient-tools-2025-02-19'],
},
};
const CLAUDE_HEADER_APPEND_BEFORE = `anthropic-beta: output-128k-2025-02-19`;
const CLAUDE_HEADER_APPEND_AFTER = `anthropic-beta: output-128k-2025-02-19,token-efficient-tools-2025-02-19`;
const CLAUDE_DEFAULT_MAX_TOKENS = {
default: 8192,
'claude-3-haiku-20240307': 4096,
@@ -114,7 +124,7 @@ export default function SettingClaudeModel(props) {
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.TextArea
label={t('Claude请求头覆盖')}
label={t('Claude请求头追加')}
field={'claude.model_headers_settings'}
placeholder={
t('为一个 JSON 文本,例如:') +
@@ -122,7 +132,20 @@ export default function SettingClaudeModel(props) {
JSON.stringify(CLAUDE_HEADER, null, 2)
}
extraText={
t('示例') + '\n' + JSON.stringify(CLAUDE_HEADER, null, 2)
<div>
<div>
{t(
'Claude会在原有请求头基础上追加这些值不会覆盖已有同名请求头重复值会自动忽略。',
)}
</div>
<div className='mt-2 whitespace-pre-wrap font-mono text-xs'>
{`${t('前:')}\n${CLAUDE_HEADER_APPEND_BEFORE}\n\n${t('配置:')}\n${JSON.stringify(
CLAUDE_HEADER_APPEND_CONFIG,
null,
2,
)}\n\n${t('后:')}\n${CLAUDE_HEADER_APPEND_AFTER}`}
</div>
</div>
}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'

View File

@@ -59,6 +59,11 @@ const formatNumber = (value) => {
return parseFloat(num.toFixed(12)).toString();
};
const toNormalizedNumber = (value) => {
const formatted = formatNumber(value);
return formatted === '' ? null : Number(formatted);
};
const parseOptionJSON = (rawValue) => {
if (!rawValue || rawValue.trim() === '') {
return {};
@@ -123,7 +128,11 @@ const buildModelState = (name, sourceMaps) => {
lockedCompletionRatio: completionRatioMeta.ratio,
completionPrice:
inputPriceNumber !== null &&
hasValue(completionRatioMeta.locked ? completionRatioMeta.ratio : completionRatio)
hasValue(
completionRatioMeta.locked
? completionRatioMeta.ratio
: completionRatio,
)
? formatNumber(
inputPriceNumber *
Number(
@@ -192,7 +201,9 @@ export const getModelWarnings = (model, t) => {
].some(hasValue);
if (model.hasConflict) {
warnings.push(t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。'));
warnings.push(
t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。'),
);
}
if (
@@ -207,11 +218,17 @@ export const getModelWarnings = (model, t) => {
].some(hasValue)
) {
warnings.push(
t('当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。'),
t(
'当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。',
),
);
}
if (model.billingMode === 'per-token' && hasDerivedPricing && !hasValue(model.inputPrice)) {
if (
model.billingMode === 'per-token' &&
hasDerivedPricing &&
!hasValue(model.inputPrice)
) {
warnings.push(t('按量计费下需要先填写输入价格,才能保存其它价格项。'));
}
@@ -249,7 +266,8 @@ export const buildSummaryText = (model, t) => {
};
export const buildOptionalFieldToggles = (model) => ({
completionPrice: model.completionRatioLocked || hasValue(model.completionPrice),
completionPrice:
model.completionRatioLocked || hasValue(model.completionPrice),
cachePrice: hasValue(model.cachePrice),
createCachePrice: hasValue(model.createCachePrice),
imagePrice: hasValue(model.imagePrice),
@@ -271,7 +289,7 @@ const serializeModel = (model, t) => {
if (model.billingMode === 'per-request') {
if (hasValue(model.fixedPrice)) {
result.ModelPrice = Number(model.fixedPrice);
result.ModelPrice = toNormalizedNumber(model.fixedPrice);
}
return result;
}
@@ -296,57 +314,68 @@ const serializeModel = (model, t) => {
if (inputPrice === null) {
if (hasDependentPrice) {
throw new Error(
t('模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率', {
name: model.name,
}),
t(
'模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率',
{
name: model.name,
},
),
);
}
if (hasValue(model.rawRatios.modelRatio)) {
result.ModelRatio = Number(model.rawRatios.modelRatio);
result.ModelRatio = toNormalizedNumber(model.rawRatios.modelRatio);
}
if (hasValue(model.rawRatios.completionRatio)) {
result.CompletionRatio = Number(model.rawRatios.completionRatio);
result.CompletionRatio = toNormalizedNumber(
model.rawRatios.completionRatio,
);
}
if (hasValue(model.rawRatios.cacheRatio)) {
result.CacheRatio = Number(model.rawRatios.cacheRatio);
result.CacheRatio = toNormalizedNumber(model.rawRatios.cacheRatio);
}
if (hasValue(model.rawRatios.createCacheRatio)) {
result.CreateCacheRatio = Number(model.rawRatios.createCacheRatio);
result.CreateCacheRatio = toNormalizedNumber(
model.rawRatios.createCacheRatio,
);
}
if (hasValue(model.rawRatios.imageRatio)) {
result.ImageRatio = Number(model.rawRatios.imageRatio);
result.ImageRatio = toNormalizedNumber(model.rawRatios.imageRatio);
}
if (hasValue(model.rawRatios.audioRatio)) {
result.AudioRatio = Number(model.rawRatios.audioRatio);
result.AudioRatio = toNormalizedNumber(model.rawRatios.audioRatio);
}
if (hasValue(model.rawRatios.audioCompletionRatio)) {
result.AudioCompletionRatio = Number(model.rawRatios.audioCompletionRatio);
result.AudioCompletionRatio = toNormalizedNumber(
model.rawRatios.audioCompletionRatio,
);
}
return result;
}
result.ModelRatio = inputPrice / 2;
result.ModelRatio = toNormalizedNumber(inputPrice / 2);
if (!model.completionRatioLocked && completionPrice !== null) {
result.CompletionRatio = completionPrice / inputPrice;
result.CompletionRatio = toNormalizedNumber(completionPrice / inputPrice);
} else if (
model.completionRatioLocked &&
hasValue(model.rawRatios.completionRatio)
) {
result.CompletionRatio = Number(model.rawRatios.completionRatio);
result.CompletionRatio = toNormalizedNumber(
model.rawRatios.completionRatio,
);
}
if (cachePrice !== null) {
result.CacheRatio = cachePrice / inputPrice;
result.CacheRatio = toNormalizedNumber(cachePrice / inputPrice);
}
if (createCachePrice !== null) {
result.CreateCacheRatio = createCachePrice / inputPrice;
result.CreateCacheRatio = toNormalizedNumber(createCachePrice / inputPrice);
}
if (imagePrice !== null) {
result.ImageRatio = imagePrice / inputPrice;
result.ImageRatio = toNormalizedNumber(imagePrice / inputPrice);
}
if (audioInputPrice !== null) {
result.AudioRatio = audioInputPrice / inputPrice;
result.AudioRatio = toNormalizedNumber(audioInputPrice / inputPrice);
}
if (audioOutputPrice !== null) {
if (audioInputPrice === null || audioInputPrice === 0) {
@@ -356,7 +385,9 @@ const serializeModel = (model, t) => {
}),
);
}
result.AudioCompletionRatio = audioOutputPrice / audioInputPrice;
result.AudioCompletionRatio = toNormalizedNumber(
audioOutputPrice / audioInputPrice,
);
}
return result;
@@ -455,7 +486,8 @@ export const buildPreviewRows = (model, t) => {
{
key: 'CacheRatio',
label: 'CacheRatio',
value: cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'),
value:
cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'),
},
{
key: 'CreateCacheRatio',
@@ -468,7 +500,8 @@ export const buildPreviewRows = (model, t) => {
{
key: 'ImageRatio',
label: 'ImageRatio',
value: imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'),
value:
imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'),
},
{
key: 'AudioRatio',
@@ -482,7 +515,9 @@ export const buildPreviewRows = (model, t) => {
key: 'AudioCompletionRatio',
label: 'AudioCompletionRatio',
value:
audioOutputPrice !== null && audioInputPrice !== null && audioInputPrice !== 0
audioOutputPrice !== null &&
audioInputPrice !== null &&
audioInputPrice !== 0
? formatNumber(audioOutputPrice / audioInputPrice)
: t('空'),
},
@@ -585,7 +620,8 @@ export function useModelPricingEditorState({
}, [currentPage, filteredModels]);
const selectedModel = useMemo(
() => visibleModels.find((model) => model.name === selectedModelName) || null,
() =>
visibleModels.find((model) => model.name === selectedModelName) || null,
[selectedModelName, visibleModels],
);
@@ -605,7 +641,9 @@ export function useModelPricingEditorState({
useEffect(() => {
setSelectedModelNames((previous) =>
previous.filter((name) => visibleModels.some((model) => model.name === name)),
previous.filter((name) =>
visibleModels.some((model) => model.name === name),
),
);
}, [visibleModels]);
@@ -779,7 +817,9 @@ export function useModelPricingEditorState({
delete next[name];
return next;
});
setSelectedModelNames((previous) => previous.filter((item) => item !== name));
setSelectedModelNames((previous) =>
previous.filter((item) => item !== name),
);
if (selectedModelName === name) {
setSelectedModelName(nextModels[0]?.name || '');
}
@@ -823,7 +863,8 @@ export function useModelPricingEditorState({
hasValue(nextModel.lockedCompletionRatio)
) {
nextModel.completionPrice = formatNumber(
Number(nextModel.inputPrice) * Number(nextModel.lockedCompletionRatio),
Number(nextModel.inputPrice) *
Number(nextModel.lockedCompletionRatio),
);
}