Feat/auto group (#6590)

* feat(token): support custom auto group order

* feat(keys): enhance auto group presentation

* fix(keys): rework Auto flow border and compact inherited order

The Auto group highlight previously tinted the whole control surface
with a gradient and animated only a 1px top sweep, which read as a
background color rather than a flowing border. Replace it with a
border-only effect: an aria-hidden, pointer-events-none overlay whose
conic gradient is masked down to a thin ring hugging the rounded
perimeter, so the highlight travels around all four edges and corners
every 3.2s. The interior stays neutral with a restrained static
primary border and glow; prefers-reduced-motion hides the moving
layer while keeping the static emphasis.

The inherited global Auto order also rendered as spacious two-line
rows with circular sequence markers, wasting drawer space. Render it
as a compact wrapping strip of one-line chips (index, name, ratio
badge) with descriptions kept accessible via title and sr-only text,
scrolling only past a much smaller max height.

Custom add/remove/reorder editing, empty-array inheritance semantics,
and the submit payload are unchanged.

* fix(keys): preserve Auto inheritance and unify effects

* refactor(keys): temporarily disable AutoGroupBadge in api-key-group-cell
This commit is contained in:
Calcium-Ion
2026-08-01 23:19:01 +08:00
committed by GitHub
parent bd585d78ef
commit 0ab0202060
57 changed files with 3922 additions and 210 deletions
+1
View File
@@ -19,6 +19,7 @@ const (
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
ContextKeyTokenAutoGroups ContextKey = "token_auto_groups"
/* channel related keys */
ContextKeyChannelId ContextKey = "channel_id"
+20 -17
View File
@@ -20,6 +20,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
@@ -190,7 +191,7 @@ func getModelListGroups(c *gin.Context) (modelListGroups, error) {
return modelListGroups{
userGroup: userGroup,
tokenGroup: tokenGroup,
ownerGroups: service.GetUserAutoGroup(userGroup),
ownerGroups: service.GetRequestAutoGroups(c, userGroup),
}, nil
}
@@ -228,32 +229,28 @@ func ListModels(c *gin.Context, modelType int) {
}
ownerGroups := groups.ownerGroups
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
var tokenModelLimit map[string]bool
if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
var tokenModelLimit map[string]bool
if ok {
tokenModelLimit = s.(map[string]bool)
} else {
tokenModelLimit, _ = s.(map[string]bool)
}
if tokenModelLimit == nil {
tokenModelLimit = map[string]bool{}
}
for allowModel, _ := range tokenModelLimit {
if !acceptUnsetRatioModel {
if !helper.HasModelBillingConfig(allowModel) {
continue
}
}
userModelNames = append(userModelNames, allowModel)
}
} else {
models := service.GetGroupsEnabledModels(ownerGroups)
for _, modelName := range models {
if !acceptUnsetRatioModel {
if !helper.HasModelBillingConfig(modelName) {
if modelLimitEnable {
matchingName := ratio_setting.FormatMatchingModelName(modelName)
if !tokenModelLimit[modelName] && !tokenModelLimit[matchingName] {
continue
}
}
userModelNames = append(userModelNames, modelName)
if !acceptUnsetRatioModel && !helper.HasModelBillingConfig(modelName) {
continue
}
userModelNames = append(userModelNames, modelName)
}
ownerByModel := map[string]string{}
@@ -276,11 +273,17 @@ func ListModels(c *gin.Context, modelType int) {
Type: "model",
}
}
firstID := ""
lastID := ""
if len(useranthropicModels) > 0 {
firstID = useranthropicModels[0].ID
lastID = useranthropicModels[len(useranthropicModels)-1].ID
}
c.JSON(200, gin.H{
"data": useranthropicModels,
"first_id": useranthropicModels[0].ID,
"first_id": firstID,
"has_more": false,
"last_id": useranthropicModels[len(useranthropicModels)-1].ID,
"last_id": lastID,
})
case constant.ChannelTypeGemini:
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
+69 -1
View File
@@ -402,7 +402,13 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
"zz-token-tiered-visible-model": `tier("base", p * 1 + c * 2)`,
"zz-token-tiered-empty-expr-model": "",
})
setupModelListControllerTestDB(t)
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "default", Model: "zz-token-tiered-visible-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-token-tiered-empty-expr-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-token-tiered-missing-expr-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-token-unpriced-model", ChannelId: 1, Enabled: true},
}).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
@@ -425,6 +431,68 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
require.NotContains(t, ids, "zz-token-unpriced-model")
}
func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) {
withSelfUseModeEnabled(t)
originalMax := setting.GetMaxTokenAutoGroups()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalRatios := ratio_setting.GroupRatio2JSONString()
require.NoError(t, setting.UpdateMaxTokenAutoGroups("5"))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
})
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "vip", Model: "zz-vip-allowed", ChannelId: 1, Enabled: true},
{Group: "vip", Model: "zz-vip-denied", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-default-outside-snapshot", ChannelId: 1, Enabled: true},
}).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "auto")
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true)
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{
"zz-vip-allowed": true,
"zz-default-outside-snapshot": true,
"zz-not-enabled": true,
})
ListModels(ctx, constant.ChannelTypeOpenAI)
ids := decodeListModelsResponse(t, recorder)
require.Equal(t, map[string]struct{}{"zz-vip-allowed": {}}, ids)
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
emptyRecorder := httptest.NewRecorder()
emptyCtx, _ := gin.CreateTestContext(emptyRecorder)
emptyCtx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
common.SetContextKey(emptyCtx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(emptyCtx, constant.ContextKeyTokenGroup, "auto")
common.SetContextKey(emptyCtx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
common.SetContextKey(emptyCtx, constant.ContextKeyTokenModelLimitEnabled, true)
common.SetContextKey(emptyCtx, constant.ContextKeyTokenModelLimit, map[string]bool{"zz-vip-allowed": true})
require.NotPanics(t, func() {
ListModels(emptyCtx, constant.ChannelTypeAnthropic)
})
var anthropicResponse struct {
Data []dto.AnthropicModel `json:"data"`
FirstID string `json:"first_id"`
LastID string `json:"last_id"`
}
require.NoError(t, common.Unmarshal(emptyRecorder.Body.Bytes(), &anthropicResponse))
require.Empty(t, anthropicResponse.Data)
require.Empty(t, anthropicResponse.FirstID)
require.Empty(t, anthropicResponse.LastID)
}
func TestCheckUpdatePasswordRequiresCurrentPassword(t *testing.T) {
db := setupModelListControllerTestDB(t)
hashedPassword, err := common.Password2Hash("CurrentPassword123")
+33
View File
@@ -1,11 +1,14 @@
package controller
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -83,3 +86,33 @@ func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) {
require.Equal(t, "vip", groups.tokenGroup)
require.Equal(t, []string{"vip"}, groups.ownerGroups)
}
func TestGetModelListGroupsUsesFilteredTokenAutoGroupsSnapshot(t *testing.T) {
originalMax := setting.GetMaxTokenAutoGroups()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalRatios := ratio_setting.GroupRatio2JSONString()
require.NoError(t, setting.UpdateMaxTokenAutoGroups("1"))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
})
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "auto")
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip", "default"})
groups, err := getModelListGroups(ctx)
require.NoError(t, err)
require.Equal(t, []string{"vip"}, groups.ownerGroups)
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
groups, err = getModelListGroups(ctx)
require.NoError(t, err)
require.Empty(t, groups.ownerGroups)
}
+124 -8
View File
@@ -7,30 +7,115 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
func buildMaskedTokenResponse(token *model.Token) *model.Token {
type tokenAutoGroupsInput struct {
Set bool
Groups []string
}
func (input *tokenAutoGroupsInput) UnmarshalJSON(data []byte) error {
input.Set = true
if strings.TrimSpace(string(data)) == "null" {
input.Groups = nil
return nil
}
return common.Unmarshal(data, &input.Groups)
}
type tokenRequest struct {
model.Token
AutoGroups tokenAutoGroupsInput `json:"auto_groups"`
}
type tokenResponse struct {
*model.Token
AutoGroups []string `json:"auto_groups"`
}
func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
if token == nil {
return nil
}
maskedToken := *token
maskedToken.Key = token.GetMaskedKey()
return &maskedToken
autoGroups, err := token.GetAutoGroups()
if err != nil {
common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
autoGroups = nil
}
if len(autoGroups) == 0 {
autoGroups = nil
}
return &tokenResponse{Token: &maskedToken, AutoGroups: autoGroups}
}
func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
maskedTokens := make([]*model.Token, 0, len(tokens))
func buildMaskedTokenResponses(tokens []*model.Token) []*tokenResponse {
maskedTokens := make([]*tokenResponse, 0, len(tokens))
for _, token := range tokens {
maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
}
return maskedTokens
}
func getTokenRequestUserGroup(c *gin.Context) (string, error) {
if userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup); userGroup != "" {
return userGroup, nil
}
if userGroup := c.GetString("group"); userGroup != "" {
return userGroup, nil
}
return model.GetUserGroup(c.GetInt("id"), false)
}
func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) bool {
if len(groups) == 0 {
if err := token.SetAutoGroups(nil); err != nil {
common.ApiError(c, err)
return false
}
return true
}
maxCount := setting.GetMaxTokenAutoGroups()
if len(groups) > maxCount {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsTooMany, map[string]any{"Max": maxCount})
return false
}
userGroup, err := getTokenRequestUserGroup(c)
if err != nil {
common.ApiError(c, err)
return false
}
seen := make(map[string]struct{}, len(groups))
for _, group := range groups {
if _, ok := seen[group]; ok {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": group})
return false
}
seen[group] = struct{}{}
if !service.IsUserSelectableGroup(userGroup, group) {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsInvalid, map[string]any{"Group": group})
return false
}
}
if err := token.SetAutoGroups(groups); err != nil {
common.ApiError(c, err)
return false
}
return true
}
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
@@ -77,6 +162,18 @@ func GetToken(c *gin.Context) {
common.ApiSuccess(c, buildMaskedTokenResponse(token))
}
func GetTokenAutoGroups(c *gin.Context) {
userGroup, err := getTokenRequestUserGroup(c)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"groups": service.GetUserAutoGroup(userGroup),
"max_count": setting.GetMaxTokenAutoGroups(),
})
}
func GetTokenKey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
@@ -165,12 +262,13 @@ func GetTokenUsage(c *gin.Context) {
}
func AddToken(c *gin.Context) {
token := model.Token{}
err := c.ShouldBindJSON(&token)
request := tokenRequest{}
err := c.ShouldBindJSON(&request)
if err != nil {
common.ApiError(c, err)
return
}
token := request.Token
if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
@@ -201,6 +299,14 @@ func AddToken(c *gin.Context) {
})
return
}
if token.Group == "auto" {
if !setTokenAutoGroups(c, &token, request.AutoGroups.Groups) {
return
}
} else {
token.CrossGroupRetry = false
_ = token.SetAutoGroups(nil)
}
key, err := common.GenerateKey()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
@@ -221,6 +327,7 @@ func AddToken(c *gin.Context) {
AllowIps: token.AllowIps,
Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry,
AutoGroups: token.AutoGroups,
}
err = cleanToken.Insert()
if err != nil {
@@ -250,12 +357,13 @@ func DeleteToken(c *gin.Context) {
func UpdateToken(c *gin.Context) {
userId := c.GetInt("id")
statusOnly := c.Query("status_only")
token := model.Token{}
err := c.ShouldBindJSON(&token)
request := tokenRequest{}
err := c.ShouldBindJSON(&request)
if err != nil {
common.ApiError(c, err)
return
}
token := request.Token
if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
@@ -299,6 +407,14 @@ func UpdateToken(c *gin.Context) {
cleanToken.AllowIps = token.AllowIps
cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry
if token.Group != "auto" {
cleanToken.CrossGroupRetry = false
_ = cleanToken.SetAutoGroups(nil)
} else if request.AutoGroups.Set {
if !setTokenAutoGroups(c, cleanToken, request.AutoGroups.Groups) {
return
}
}
}
err = cleanToken.Update()
if err != nil {
+234
View File
@@ -0,0 +1,234 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func configureTokenAutoGroupsTest(t *testing.T, maxCount string, autoGroups string) {
t.Helper()
originalMax := setting.GetMaxTokenAutoGroups()
originalAutoGroups := setting.AutoGroups2JsonString()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalRatios := ratio_setting.GroupRatio2JSONString()
require.NoError(t, setting.UpdateMaxTokenAutoGroups(maxCount))
require.NoError(t, setting.UpdateAutoGroupsByJsonString(autoGroups))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateMaxTokenAutoGroups(stringInt(originalMax)))
require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
})
}
func stringInt(value int) string {
return fmt.Sprintf("%d", value)
}
func setupTokenAutoGroupsControllerTest(t *testing.T) *model.User {
t.Helper()
db := setupTokenControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.User{}))
user := &model.User{
Id: 101,
Username: "token-auto-user",
Password: "password",
Group: "default",
Status: common.UserStatusEnabled,
}
require.NoError(t, db.Create(user).Error)
return user
}
func baseAutoTokenRequest(name string) map[string]any {
return map[string]any{
"name": name,
"expired_time": -1,
"remain_quota": 0,
"unlimited_quota": true,
"group": "auto",
"cross_group_retry": true,
}
}
func newTokenAutoGroupsAuthenticatedContext(t *testing.T, method string, target string, body any, userID int) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
ctx, recorder := newAuthenticatedContext(t, method, target, body, userID)
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
return ctx, recorder
}
func TestAddTokenEmptyAutoGroupsInheritGlobalAuto(t *testing.T) {
tests := []struct {
name string
includeField bool
value any
}{
{name: "omitted"},
{name: "null", includeField: true, value: nil},
{name: "empty array", includeField: true, value: []string{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
user := setupTokenAutoGroupsControllerTest(t)
request := baseAutoTokenRequest("create-" + test.name)
if test.includeField {
request["auto_groups"] = test.value
}
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
AddToken(ctx)
response := decodeAPIResponse(t, recorder)
require.True(t, response.Success, response.Message)
var token model.Token
require.NoError(t, model.DB.Where("name = ?", request["name"]).First(&token).Error)
assert.Empty(t, token.AutoGroups)
assert.True(t, token.CrossGroupRetry)
payload, err := common.Marshal(buildMaskedTokenResponse(&token))
require.NoError(t, err)
var responseData map[string]any
require.NoError(t, common.Unmarshal(payload, &responseData))
assert.Nil(t, responseData["auto_groups"])
})
}
}
func TestAddTokenPersistsOrderedAutoGroupsSnapshot(t *testing.T) {
configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
user := setupTokenAutoGroupsControllerTest(t)
request := baseAutoTokenRequest("ordered-snapshot")
request["auto_groups"] = []string{"vip", "default"}
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
AddToken(ctx)
require.True(t, decodeAPIResponse(t, recorder).Success)
var token model.Token
require.NoError(t, model.DB.Where("name = ?", "ordered-snapshot").First(&token).Error)
assert.JSONEq(t, `["vip","default"]`, token.AutoGroups)
getCtx, getRecorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodGet, "/api/token/"+stringInt(token.Id), nil, user.Id)
getCtx.Params = append(getCtx.Params, gin.Param{Key: "id", Value: stringInt(token.Id)})
GetToken(getCtx)
getResponse := decodeAPIResponse(t, getRecorder)
require.True(t, getResponse.Success)
var data struct {
AutoGroups []string `json:"auto_groups"`
}
require.NoError(t, common.Unmarshal(getResponse.Data, &data))
assert.Equal(t, []string{"vip", "default"}, data.AutoGroups)
}
func TestUpdateTokenAutoGroupsTriStateAndNonAutoCleanup(t *testing.T) {
tests := []struct {
name string
includeField bool
value any
group string
expectedAutoGroups string
expectedRetry bool
}{
{name: "omitted preserves", group: "auto", expectedAutoGroups: `["vip","default"]`, expectedRetry: true},
{name: "null inherits", includeField: true, value: nil, group: "auto", expectedRetry: true},
{name: "empty inherits", includeField: true, value: []string{}, group: "auto", expectedRetry: true},
{name: "non auto clears and disables retry", includeField: true, value: []string{"vip"}, group: "default"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
user := setupTokenAutoGroupsControllerTest(t)
token := seedToken(t, model.DB, user.Id, "update-auto", "update-auto-key")
token.Group = "auto"
token.CrossGroupRetry = true
require.NoError(t, token.SetAutoGroups([]string{"vip", "default"}))
require.NoError(t, model.DB.Save(token).Error)
request := baseAutoTokenRequest("updated-auto")
request["id"] = token.Id
request["status"] = common.TokenStatusEnabled
request["group"] = test.group
if test.includeField {
request["auto_groups"] = test.value
}
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPut, "/api/token/", request, user.Id)
UpdateToken(ctx)
response := decodeAPIResponse(t, recorder)
require.True(t, response.Success, response.Message)
var updated model.Token
require.NoError(t, model.DB.First(&updated, token.Id).Error)
if test.expectedAutoGroups == "" {
assert.Empty(t, updated.AutoGroups)
} else {
assert.JSONEq(t, test.expectedAutoGroups, updated.AutoGroups)
}
assert.Equal(t, test.expectedRetry, updated.CrossGroupRetry)
})
}
}
func TestAddTokenRejectsInvalidAutoGroups(t *testing.T) {
tests := []struct {
name string
maxCount string
groups []string
}{
{name: "over limit", maxCount: "1", groups: []string{"default", "vip"}},
{name: "duplicate", maxCount: "5", groups: []string{"default", "default"}},
{name: "auto pseudo group", maxCount: "5", groups: []string{"auto"}},
{name: "unavailable", maxCount: "5", groups: []string{"missing"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
configureTokenAutoGroupsTest(t, test.maxCount, `["default","vip"]`)
user := setupTokenAutoGroupsControllerTest(t)
request := baseAutoTokenRequest("invalid-" + test.name)
request["auto_groups"] = test.groups
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
AddToken(ctx)
response := decodeAPIResponse(t, recorder)
assert.False(t, response.Success)
var count int64
require.NoError(t, model.DB.Model(&model.Token{}).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestGetTokenAutoGroupsReturnsFullFilteredGlobalOrderAndLimit(t *testing.T) {
configureTokenAutoGroupsTest(t, "1", `["vip","missing","default"]`)
user := setupTokenAutoGroupsControllerTest(t)
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodGet, "/api/token/auto-groups", nil, user.Id)
GetTokenAutoGroups(ctx)
response := decodeAPIResponse(t, recorder)
require.True(t, response.Success, response.Message)
var data struct {
Groups []string `json:"groups"`
MaxCount int `json:"max_count"`
}
require.NoError(t, common.Unmarshal(response.Data, &data))
assert.Equal(t, []string{"vip", "default"}, data.Groups)
assert.Equal(t, 1, data.MaxCount)
}
+40
View File
@@ -273,6 +273,34 @@ func getTokenKeyColumnType(t *testing.T, db *gorm.DB, dialect string) string {
}
}
func getTokenAutoGroupsColumnType(t *testing.T, db *gorm.DB, dialect string) string {
t.Helper()
switch dialect {
case "sqlite":
return getSQLiteColumnType(t, db, "tokens", "auto_groups")
case "mysql":
var columnType string
if err := db.Raw(`SELECT DATA_TYPE FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
"tokens", "auto_groups").Scan(&columnType).Error; err != nil {
t.Fatalf("failed to inspect mysql token auto_groups column: %v", err)
}
return strings.ToLower(columnType)
case "postgres":
var dataType string
if err := db.Raw(`SELECT data_type FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`,
"tokens", "auto_groups").Scan(&dataType).Error; err != nil {
t.Fatalf("failed to inspect postgres token auto_groups column: %v", err)
}
return strings.ToLower(dataType)
default:
t.Fatalf("unsupported dialect %q", dialect)
return ""
}
}
func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect string, managedTokensTable *bool) {
t.Helper()
@@ -314,6 +342,12 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" {
t.Fatalf("expected migrated key column type varchar(128), got %q", got)
}
if !db.Migrator().HasColumn(&model.Token{}, "auto_groups") {
t.Fatal("expected migration to add auto_groups column")
}
if got := getTokenAutoGroupsColumnType(t, db, dialect); got != "text" {
t.Fatalf("expected migrated auto_groups column type text, got %q", got)
}
var migratedToken model.Token
if err := db.First(&migratedToken, "name = ?", "legacy-token").Error; err != nil {
@@ -325,6 +359,9 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
if migratedToken.Name != "legacy-token" {
t.Fatalf("expected migrated token name to be preserved, got %q", migratedToken.Name)
}
if migratedToken.AutoGroups != "" {
t.Fatalf("expected legacy token to inherit global Auto groups, got %q", migratedToken.AutoGroups)
}
inserted := model.Token{
UserId: 8,
@@ -362,6 +399,9 @@ func TestTokenAutoMigrateUsesVarchar128KeyColumn(t *testing.T) {
if got := getTokenKeyColumnType(t, db, "sqlite"); got != "varchar(128)" {
t.Fatalf("expected key column type varchar(128), got %q", got)
}
if got := getSQLiteColumnType(t, db, "tokens", "auto_groups"); got != "text" {
t.Fatalf("expected auto_groups column type text, got %q", got)
}
}
func TestTokenMigrationFromChar48ToVarchar128(t *testing.T) {
+3
View File
@@ -55,6 +55,9 @@ const (
MsgTokenExhausted = "token.exhausted"
MsgTokenStatusUnavailable = "token.status_unavailable"
MsgTokenDbError = "token.db_error"
MsgTokenAutoGroupsTooMany = "token.auto_groups_too_many"
MsgTokenAutoGroupsDuplicate = "token.auto_groups_duplicate"
MsgTokenAutoGroupsInvalid = "token.auto_groups_invalid"
)
// Redemption related messages
+3
View File
@@ -47,6 +47,9 @@ token.expired: "This token has expired"
token.exhausted: "This token quota is exhausted TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "This token status is unavailable"
token.db_error: "Invalid token, database query error, please contact administrator"
token.auto_groups_too_many: "A token can select at most {{.Max}} Auto groups"
token.auto_groups_duplicate: "Auto group {{.Group}} is duplicated"
token.auto_groups_invalid: "Auto group {{.Group}} is unavailable or unauthorized"
# Redemption messages
redemption.name_length: "Redemption code name length must be between 1-20"
+3
View File
@@ -48,6 +48,9 @@ token.expired: "该令牌已过期"
token.exhausted: "该令牌额度已用尽 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "该令牌状态不可用"
token.db_error: "无效的令牌,数据库查询出错,请联系管理员"
token.auto_groups_too_many: "每个令牌最多可选择 {{.Max}} 个 Auto 分组"
token.auto_groups_duplicate: "Auto 分组 {{.Group}} 重复"
token.auto_groups_invalid: "Auto 分组 {{.Group}} 不可用或无权访问"
# Redemption messages
redemption.name_length: "兑换码名称长度必须在1-20之间"
+3
View File
@@ -48,6 +48,9 @@ token.expired: "該令牌已過期"
token.exhausted: "該令牌額度已用盡 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "該令牌狀態不可用"
token.db_error: "無效的令牌,資料庫查詢出錯,請聯繫管理員"
token.auto_groups_too_many: "每個令牌最多可選擇 {{.Max}} 個 Auto 分組"
token.auto_groups_duplicate: "Auto 分組 {{.Group}} 重複"
token.auto_groups_invalid: "Auto 分組 {{.Group}} 不可用或無權存取"
# Redemption messages
redemption.name_length: "兌換碼名稱長度必須在1-20之間"
+10
View File
@@ -502,6 +502,16 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
}
common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group)
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry)
if token.AutoGroups != "" {
autoGroups, err := token.GetAutoGroups()
if err != nil {
common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
autoGroups = []string{}
common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
} else if len(autoGroups) > 0 {
common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
}
}
if len(parts) > 1 {
if model.IsAdmin(token.UserId) {
c.Set("specific_channel_id", parts[1])
+1 -1
View File
@@ -109,7 +109,7 @@ func Distribute() func(c *gin.Context) {
channelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
autoGroups := service.GetRequestAutoGroups(c, userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
@@ -0,0 +1,48 @@
package middleware
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTokenAutoGroupsContext() *gin.Context {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
return ctx
}
func TestSetupContextForTokenPreservesCustomAutoGroupsOrder(t *testing.T) {
ctx := newTokenAutoGroupsContext()
token := &model.Token{Id: 1, UserId: 2, AutoGroups: `["vip","default"]`}
require.NoError(t, SetupContextForToken(ctx, token))
value, ok := common.GetContextKey(ctx, constant.ContextKeyTokenAutoGroups)
require.True(t, ok)
assert.Equal(t, []string{"vip", "default"}, value)
}
func TestSetupContextForTokenTreatsStoredEmptyArrayAsInheritance(t *testing.T) {
ctx := newTokenAutoGroupsContext()
token := &model.Token{Id: 1, UserId: 2, AutoGroups: `[]`}
require.NoError(t, SetupContextForToken(ctx, token))
_, ok := common.GetContextKey(ctx, constant.ContextKeyTokenAutoGroups)
assert.False(t, ok)
}
func TestSetupContextForTokenMalformedAutoGroupsFailsClosed(t *testing.T) {
ctx := newTokenAutoGroupsContext()
token := &model.Token{Id: 1, UserId: 2, AutoGroups: `not-json`}
require.NoError(t, SetupContextForToken(ctx, token))
value, ok := common.GetContextKey(ctx, constant.ContextKeyTokenAutoGroups)
require.True(t, ok)
assert.Equal(t, []string{}, value)
}
+6
View File
@@ -120,6 +120,7 @@ func InitOptionMap() {
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
common.OptionMap["MaxTokenAutoGroups"] = strconv.Itoa(setting.GetMaxTokenAutoGroups())
common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString()
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
@@ -208,6 +209,9 @@ func validateOptionValue(key string, value string) error {
if key == operation_setting.ToolPriceOptionKey {
return operation_setting.ValidateToolPricesJSON(value)
}
if key == "MaxTokenAutoGroups" {
return setting.ValidateMaxTokenAutoGroups(value)
}
return nil
}
@@ -413,6 +417,8 @@ func updateOptionMap(key string, value string) (err error) {
err = setting.UpdateChatsByJsonString(value)
case "AutoGroups":
err = setting.UpdateAutoGroupsByJsonString(value)
case "MaxTokenAutoGroups":
err = setting.UpdateMaxTokenAutoGroups(value)
case "CustomCallbackAddress":
operation_setting.CustomCallbackAddress = value
case "EpayId":
+17
View File
@@ -0,0 +1,17 @@
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateOptionValueRejectsInvalidMaxTokenAutoGroups(t *testing.T) {
for _, value := range []string{"", "0", "-1", "1.5", "invalid"} {
t.Run(value, func(t *testing.T) {
assert.Error(t, validateOptionValue("MaxTokenAutoGroups", value))
})
}
require.NoError(t, validateOptionValue("MaxTokenAutoGroups", "999999"))
}
+34 -11
View File
@@ -28,9 +28,34 @@ type Token struct {
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
AutoGroups string `json:"-" gorm:"type:text"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (token *Token) GetAutoGroups() ([]string, error) {
if token.AutoGroups == "" {
return nil, nil
}
var groups []string
if err := common.UnmarshalJsonStr(token.AutoGroups, &groups); err != nil {
return nil, err
}
return groups, nil
}
func (token *Token) SetAutoGroups(groups []string) error {
if len(groups) == 0 {
token.AutoGroups = ""
return nil
}
data, err := common.Marshal(groups)
if err != nil {
return err
}
token.AutoGroups = string(data)
return nil
}
func (token *Token) Clean() {
token.Key = ""
}
@@ -291,18 +316,16 @@ func (token *Token) Insert() error {
// Update Make sure your token's fields is completed, because this will update non-zero values
func (token *Token) Update() (err error) {
defer func() {
if shouldUpdateRedis(true, err) {
gopool.Go(func() {
err := cacheSetToken(*token)
if err != nil {
common.SysLog("failed to update token cache: " + err.Error())
}
})
}
}()
err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
"model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error
"model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "auto_groups").Updates(token).Error
if shouldUpdateRedis(true, err) {
if cacheErr := cacheSetToken(*token); cacheErr != nil {
common.SysLog("failed to update token cache: " + cacheErr.Error())
if deleteErr := cacheDeleteToken(token.Key); deleteErr != nil {
common.SysLog("failed to invalidate token cache after update: " + deleteErr.Error())
}
}
}
return err
}
+57
View File
@@ -0,0 +1,57 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTokenAutoGroupsRoundTripThroughRedisHashCache(t *testing.T) {
useUserCacheMiniRedis(t)
token := Token{
Id: 42,
UserId: 7,
Key: "token-auto-groups-cache-key",
Name: "auto-cache",
Group: "auto",
AutoGroups: `["vip","default"]`,
}
require.NoError(t, cacheSetToken(token))
cached, err := cacheGetTokenByKey(token.Key)
require.NoError(t, err)
assert.Equal(t, token.AutoGroups, cached.AutoGroups)
groups, err := cached.GetAutoGroups()
require.NoError(t, err)
assert.Equal(t, []string{"vip", "default"}, groups)
}
func TestTokenUpdateSynchronouslyNarrowsPreheatedAutoGroupsCache(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
token := Token{
UserId: 7,
Key: "token-auto-groups-update-cache-key",
Name: "auto-cache-update",
Status: common.TokenStatusEnabled,
ExpiredTime: -1,
UnlimitedQuota: true,
Group: "auto",
CrossGroupRetry: true,
AutoGroups: `["default","vip"]`,
}
require.NoError(t, token.Insert())
require.NoError(t, cacheSetToken(token))
preheated, err := cacheGetTokenByKey(token.Key)
require.NoError(t, err)
assert.JSONEq(t, `["default","vip"]`, preheated.AutoGroups)
require.NoError(t, token.SetAutoGroups([]string{"vip"}))
require.NoError(t, token.Update())
immediate, err := cacheGetTokenByKey(token.Key)
require.NoError(t, err)
assert.JSONEq(t, `["vip"]`, immediate.AutoGroups)
}
+1
View File
@@ -238,6 +238,7 @@ func SetApiRouter(router *gin.Engine) {
{
tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
tokenRoute.GET("/auto-groups", controller.GetTokenAutoGroups)
tokenRoute.GET("/:id", controller.GetToken)
tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey)
tokenRoute.POST("/", controller.AddToken)
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
)
@@ -88,10 +87,10 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup)
if param.TokenGroup == "auto" {
if len(setting.GetAutoGroups()) == 0 {
autoGroups := GetRequestAutoGroups(param.Ctx, userGroup)
if len(autoGroups) == 0 {
return nil, selectGroup, errors.New("auto groups is not enabled")
}
autoGroups := GetUserAutoGroup(userGroup)
// startGroupIndex: the group index to start searching from
// startGroupIndex: 开始搜索的分组索引
+129
View File
@@ -0,0 +1,129 @@
package service
import (
"fmt"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupChannelSelectAutoGroupsTest(t *testing.T) *gorm.DB {
t.Helper()
originalDB := model.DB
originalMemoryCacheEnabled := common.MemoryCacheEnabled
originalRetryTimes := common.RetryTimes
originalAutoGroups := setting.AutoGroups2JsonString()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalGroupRatios := ratio_setting.GroupRatio2JSONString()
originalMaxTokenAutoGroups := setting.GetMaxTokenAutoGroups()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}))
model.DB = db
common.MemoryCacheEnabled = true
common.RetryTimes = 0
require.NoError(t, setting.UpdateAutoGroupsByJsonString(`[]`))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":2}`))
require.NoError(t, setting.UpdateMaxTokenAutoGroups("2"))
t.Cleanup(func() {
model.DB = originalDB
common.MemoryCacheEnabled = originalMemoryCacheEnabled
common.RetryTimes = originalRetryTimes
require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMaxTokenAutoGroups)))
if originalMemoryCacheEnabled && originalDB != nil &&
originalDB.Migrator().HasTable(&model.Channel{}) && originalDB.Migrator().HasTable(&model.Ability{}) {
model.InitChannelCache()
}
sqlDB, err := db.DB()
if err == nil {
require.NoError(t, sqlDB.Close())
}
})
return db
}
func createChannelSelectAutoGroupsChannel(t *testing.T, db *gorm.DB, id int, group, modelName string) {
t.Helper()
priority := int64(0)
weight := uint(100)
require.NoError(t, db.Create(&model.Channel{
Id: id,
Type: constant.ChannelTypeOpenAI,
Key: fmt.Sprintf("key-%d", id),
Status: common.ChannelStatusEnabled,
Name: fmt.Sprintf("channel-%d", id),
Weight: &weight,
Models: modelName,
Group: group,
Priority: &priority,
}).Error)
require.NoError(t, db.Create(&model.Ability{
Group: group,
Model: modelName,
ChannelId: id,
Enabled: true,
Priority: &priority,
Weight: weight,
}).Error)
}
func TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "auto-groups-runtime-model"
createChannelSelectAutoGroupsChannel(t, db, 2101, "vip", modelName)
createChannelSelectAutoGroupsChannel(t, db, 2102, "default", modelName)
model.InitChannelCache()
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip", "default"})
common.SetContextKey(ctx, constant.ContextKeyTokenCrossGroupRetry, true)
retry := 0
param := &RetryParam{
Ctx: ctx,
TokenGroup: "auto",
ModelName: modelName,
RequestPath: "/v1/chat/completions",
Retry: &retry,
}
first, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, first)
assert.Equal(t, 2101, first.Id)
assert.Equal(t, "vip", selectedGroup)
assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
assert.Empty(t, setting.GetAutoGroups(), "the selection must not depend on the global Auto list")
param.IncreaseRetry()
second, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, second)
assert.Equal(t, 2102, second.Id)
assert.Equal(t, "default", selectedGroup)
assert.Equal(t, "default", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
}
+55 -3
View File
@@ -3,9 +3,12 @@ package service
import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
func GetUserUsableGroups(userGroup string) map[string]string {
@@ -42,18 +45,67 @@ func GroupInUserUsableGroups(userGroup, groupName string) bool {
return ok
}
func IsUserSelectableGroup(userGroup, groupName string) bool {
if groupName == "" || groupName == "auto" {
return false
}
return GroupInUserUsableGroups(userGroup, groupName) && ratio_setting.ContainsGroupRatio(groupName)
}
// GetUserAutoGroup 根据用户分组获取自动分组设置
func GetUserAutoGroup(userGroup string) []string {
groups := GetUserUsableGroups(userGroup)
autoGroups := make([]string, 0)
seen := make(map[string]struct{})
for _, group := range setting.GetAutoGroups() {
if _, ok := groups[group]; ok {
if !IsUserSelectableGroup(userGroup, group) {
continue
}
if _, ok := seen[group]; ok {
continue
}
seen[group] = struct{}{}
autoGroups = append(autoGroups, group)
}
}
return autoGroups
}
// FilterUserTokenAutoGroups applies current permissions before the current
// per-token limit. It intentionally does not fall back to the global Auto list.
func FilterUserTokenAutoGroups(userGroup string, groups []string) []string {
maxCount := setting.GetMaxTokenAutoGroups()
filtered := make([]string, 0, min(len(groups), maxCount))
seen := make(map[string]struct{})
for _, group := range groups {
if !IsUserSelectableGroup(userGroup, group) {
continue
}
if _, ok := seen[group]; ok {
continue
}
seen[group] = struct{}{}
filtered = append(filtered, group)
if len(filtered) == maxCount {
break
}
}
return filtered
}
// GetRequestAutoGroups resolves the ordered Auto groups for the current token.
// The absence of the context value means that the token inherits the complete
// global Auto list; a present (even empty) value is an explicit token snapshot.
func GetRequestAutoGroups(c *gin.Context, userGroup string) []string {
value, ok := common.GetContextKey(c, constant.ContextKeyTokenAutoGroups)
if !ok {
return GetUserAutoGroup(userGroup)
}
groups, ok := value.([]string)
if !ok {
return []string{}
}
return FilterUserTokenAutoGroups(userGroup, groups)
}
// GetGroupsEnabledModels 按 groups 顺序获取各分组启用的模型并去重
func GetGroupsEnabledModels(groups []string) []string {
seen := make(map[string]struct{})
+72
View File
@@ -0,0 +1,72 @@
package service
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func configureRequestAutoGroupsTest(t *testing.T) {
t.Helper()
originalMax := setting.GetMaxTokenAutoGroups()
originalAutoGroups := setting.AutoGroups2JsonString()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalRatios := ratio_setting.GroupRatio2JSONString()
require.NoError(t, setting.UpdateMaxTokenAutoGroups("2"))
require.NoError(t, setting.UpdateAutoGroupsByJsonString(`["vip","default","svip"]`))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP","svip":"SVIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1,"svip":1}`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
})
}
func newRequestAutoGroupsContext() *gin.Context {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
return ctx
}
func TestGetRequestAutoGroupsInheritedListIsNotLimited(t *testing.T) {
configureRequestAutoGroupsTest(t)
ctx := newRequestAutoGroupsContext()
groups := GetRequestAutoGroups(ctx, "default")
assert.Equal(t, []string{"vip", "default", "svip"}, groups)
}
func TestGetRequestAutoGroupsFiltersBeforeApplyingCurrentLimit(t *testing.T) {
configureRequestAutoGroupsTest(t)
ctx := newRequestAutoGroupsContext()
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"revoked", "vip", "default", "svip"})
require.NoError(t, setting.UpdateAutoGroupsByJsonString(`[]`))
groups := GetRequestAutoGroups(ctx, "default")
assert.Equal(t, []string{"vip", "default"}, groups)
require.NoError(t, setting.UpdateMaxTokenAutoGroups("1"))
assert.Equal(t, []string{"vip"}, GetRequestAutoGroups(ctx, "default"))
}
func TestGetRequestAutoGroupsDoesNotFallBackAfterPermissionChange(t *testing.T) {
configureRequestAutoGroupsTest(t)
ctx := newRequestAutoGroupsContext()
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
groups := GetRequestAutoGroups(ctx, "default")
assert.Empty(t, groups)
}
+33
View File
@@ -1,15 +1,27 @@
package setting
import (
"fmt"
"strconv"
"sync/atomic"
"github.com/QuantumNous/new-api/common"
)
const DefaultMaxTokenAutoGroups = 5
var autoGroups = []string{
"default",
}
var DefaultUseAutoGroup = false
var maxTokenAutoGroups atomic.Int64
func init() {
maxTokenAutoGroups.Store(DefaultMaxTokenAutoGroups)
}
func ContainsAutoGroup(group string) bool {
for _, autoGroup := range autoGroups {
if autoGroup == group {
@@ -35,3 +47,24 @@ func AutoGroups2JsonString() string {
func GetAutoGroups() []string {
return autoGroups
}
func GetMaxTokenAutoGroups() int {
return int(maxTokenAutoGroups.Load())
}
func ValidateMaxTokenAutoGroups(value string) error {
maxCount, err := strconv.Atoi(value)
if err != nil || maxCount <= 0 {
return fmt.Errorf("MaxTokenAutoGroups must be a positive integer")
}
return nil
}
func UpdateMaxTokenAutoGroups(value string) error {
if err := ValidateMaxTokenAutoGroups(value); err != nil {
return err
}
maxCount, _ := strconv.Atoi(value)
maxTokenAutoGroups.Store(int64(maxCount))
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package setting
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateMaxTokenAutoGroupsAcceptsAnyPositiveInteger(t *testing.T) {
original := GetMaxTokenAutoGroups()
t.Cleanup(func() {
require.NoError(t, UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", original)))
})
require.NoError(t, UpdateMaxTokenAutoGroups("123456"))
assert.Equal(t, 123456, GetMaxTokenAutoGroups())
}
func TestUpdateMaxTokenAutoGroupsRejectsInvalidValuesWithoutChangingState(t *testing.T) {
original := GetMaxTokenAutoGroups()
for _, value := range []string{"", "0", "-1", "1.5", "not-a-number"} {
t.Run(value, func(t *testing.T) {
assert.Error(t, UpdateMaxTokenAutoGroups(value))
assert.Equal(t, original, GetMaxTokenAutoGroups())
})
}
}
+9
View File
@@ -25,6 +25,7 @@ import type {
GetApiKeysResponse,
SearchApiKeysParams,
ApiKeyFormData,
TokenAutoGroupsConfig,
} from './types'
// ============================================================================
@@ -60,6 +61,14 @@ export async function getApiKey(id: number): Promise<ApiResponse<ApiKey>> {
return res.data
}
// Get the current user's global Auto order and the per-token selection limit.
export async function getTokenAutoGroups(): Promise<
ApiResponse<TokenAutoGroupsConfig>
> {
const res = await api.get('/api/token/auto-groups')
return res.data
}
// Create a new API key
export async function createApiKey(
data: ApiKeyFormData
@@ -0,0 +1,236 @@
/*
Copyright (C) 2023-2026 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 assert from 'node:assert/strict'
import { after, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'SVGElement',
'Node',
'Element',
'Event',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
const { act } = await import('react')
const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { TooltipProvider } = await import('@/components/ui/tooltip')
const { ApiKeyGroupCell } = await import('../api-key-group-cell')
const i18n = createInstance()
await i18n.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
Auto: 'Auto',
'Cross-group': 'Cross-group',
Ratio: 'Ratio',
'Automatically selects the best available group with circuit breaker mechanism':
'Automatically selects the best available group with circuit breaker mechanism',
},
},
},
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
function CellHarness(props: {
group: string
ratio?: number | string
crossGroupRetry?: boolean
shouldReduceMotion?: boolean
}) {
return (
<I18nextProvider i18n={i18n}>
<TooltipProvider>
<ApiKeyGroupCell
group={props.group}
ratio={props.ratio}
crossGroupRetry={props.crossGroupRetry ?? false}
shouldReduceMotion={props.shouldReduceMotion ?? false}
/>
</TooltipProvider>
</I18nextProvider>
)
}
describe('API key group table cell', () => {
after(() => {
domWindow.close()
})
test('renders two unclipped rings and a localized Auto ratio when API data uses a nonlocalized string', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(
<CellHarness
group='auto'
ratio='自动'
crossGroupRetry
shouldReduceMotion={false}
/>
)
)
const badgeCell = container.querySelector<HTMLElement>(
'[data-api-key-group-cell="auto"]'
)
assert.ok(badgeCell)
assert.equal(badgeCell.classList.contains('overflow-visible'), true)
assert.equal(badgeCell.classList.contains('overflow-hidden'), false)
const frames = container.querySelectorAll('[data-auto-group-frame]')
const movingRings = container.querySelectorAll(
'[data-auto-group-flow-border]'
)
assert.equal(frames.length, 2)
assert.equal(movingRings.length, 2)
for (const frame of frames) {
assert.equal(frame.classList.contains('relative'), true)
assert.equal(frame.classList.contains('overflow-visible'), true)
assert.equal(frame.classList.contains('rounded-4xl'), true)
assert.equal(frame.classList.contains('p-px'), true)
}
const ratio = container.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
assert.ok(ratio)
assert.equal(ratio.textContent, 'Auto Ratio')
assert.equal(ratio.textContent?.includes('x'), false)
assert.equal(container.textContent?.includes('自动'), false)
assert.equal(container.textContent?.includes('Cross-group'), true)
const crossGroupBadge = [
...container.querySelectorAll<HTMLElement>('[data-slot="status-badge"]'),
].find((badge) => badge.textContent === 'Cross-group')
assert.ok(crossGroupBadge)
assert.equal(crossGroupBadge.closest('[data-auto-group-frame]'), null)
await act(async () => root.unmount())
container.remove()
})
test('keeps static Auto frames but omits both moving layers for reduced motion', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(<CellHarness group='auto' ratio='Auto' shouldReduceMotion />)
)
assert.equal(
container.querySelectorAll('[data-auto-group-frame]').length,
2
)
assert.equal(
container.querySelectorAll('[data-auto-group-flow-border]').length,
0
)
await act(async () => root.unmount())
container.remove()
})
test('shows only the Auto badge when ratio data is unavailable', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(<CellHarness group='auto' shouldReduceMotion={false} />)
)
assert.equal(
container.querySelectorAll('[data-auto-group-frame]').length,
1
)
assert.equal(
container.querySelectorAll('[data-auto-group-flow-border]').length,
1
)
assert.equal(
container.querySelector('[data-auto-group-effect="ratio"]'),
null
)
assert.equal(container.textContent?.includes('Auto'), true)
assert.equal(container.textContent?.includes('Ratio'), false)
await act(async () => root.unmount())
container.remove()
})
test('narrows normal group ratios to numbers and never applies Auto rings', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(
<CellHarness group='vip' ratio='自动' shouldReduceMotion={false} />
)
)
assert.equal(container.textContent?.includes('vip'), true)
assert.equal(container.textContent?.includes('自动'), false)
assert.equal(container.querySelector('[data-auto-group-frame]'), null)
assert.equal(container.querySelector('[data-auto-group-flow-border]'), null)
await act(async () =>
root.render(
<CellHarness group='vip' ratio={3} shouldReduceMotion={false} />
)
)
assert.equal(container.textContent?.includes('3x'), true)
assert.equal(container.querySelector('[data-auto-group-frame]'), null)
await act(async () => root.unmount())
container.remove()
})
})
@@ -0,0 +1,294 @@
/*
Copyright (C) 2023-2026 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 assert from 'node:assert/strict'
import { after, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'HTMLInputElement',
'SVGElement',
'Node',
'Element',
'Event',
'KeyboardEvent',
'PointerEvent',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
let shouldReduceMotion = false
const reducedMotionMediaQuery = domWindow.matchMedia('(prefers-reduced-motion)')
Object.defineProperty(reducedMotionMediaQuery, 'matches', {
configurable: true,
get: () => shouldReduceMotion,
})
Object.defineProperty(domWindow, 'matchMedia', {
configurable: true,
value: () => reducedMotionMediaQuery,
})
function setReducedMotion(value: boolean) {
shouldReduceMotion = value
reducedMotionMediaQuery.dispatchEvent(new domWindow.Event('change'))
}
const { act, useState } = await import('react')
const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { ApiKeyGroupCombobox } = await import('../api-key-group-combobox')
const i18n = createInstance()
await i18n.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
Auto: 'Auto',
Ratio: 'Ratio',
'Search...': 'Search...',
'No group found.': 'No group found.',
'Select a group': 'Select a group',
},
},
},
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
const options = [
{
value: 'auto',
label: 'auto',
desc: 'Global automatic routing',
ratio: '自动',
},
{ value: 'default', label: 'default', desc: 'User group', ratio: 1 },
{ value: 'vip', label: 'vip', desc: 'Priority group', ratio: 3 },
]
function Harness(props: { initialValue: string }) {
const [value, setValue] = useState(props.initialValue)
return (
<I18nextProvider i18n={i18n}>
<ApiKeyGroupCombobox
options={options}
value={value}
onValueChange={setValue}
/>
<output data-testid='selected-group'>{value}</output>
</I18nextProvider>
)
}
function getTrigger(container: ParentNode): HTMLButtonElement {
const trigger = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(trigger)
return trigger
}
function getCommandItem(label: string): HTMLElement {
const item = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
].find((candidate) => candidate.textContent?.includes(label))
assert.ok(item)
return item
}
describe('API key group combobox Auto effect', () => {
after(() => {
domWindow.close()
})
test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', async () => {
setReducedMotion(false)
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness initialValue='auto' />))
const trigger = getTrigger(container)
assert.equal(trigger.getAttribute('aria-expanded'), 'false')
assert.equal(trigger.dataset.autoGroupEffect, 'trigger')
assert.equal(trigger.classList.contains('bg-linear-to-r'), false)
assert.equal(trigger.classList.contains('overflow-hidden'), false)
assert.equal(trigger.classList.contains('overflow-visible'), true)
const triggerFlowBorder = trigger.querySelector<HTMLElement>(
'[data-auto-group-flow-border]'
)
assert.ok(triggerFlowBorder)
assert.equal(triggerFlowBorder.getAttribute('aria-hidden'), 'true')
assert.equal(
triggerFlowBorder.classList.contains('pointer-events-none'),
true
)
assert.equal(
triggerFlowBorder.classList.contains('auto-group-flow-border'),
true
)
const triggerRatio = trigger.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
assert.ok(triggerRatio)
assert.equal(triggerRatio.textContent, 'Auto Ratio')
assert.equal(triggerRatio.textContent?.includes('Auto'), true)
assert.equal(triggerRatio.textContent?.includes('x'), false)
assert.equal(trigger.textContent?.includes('自动'), false)
assert.equal(triggerRatio.classList.contains('relative'), true)
assert.equal(triggerRatio.classList.contains('overflow-visible'), true)
assert.equal(triggerRatio.classList.contains('rounded-4xl'), true)
assert.ok(triggerRatio.querySelector('[data-auto-group-flow-border]'))
await act(async () => trigger.click())
assert.equal(trigger.getAttribute('aria-expanded'), 'true')
const autoOption = getCommandItem('Global automatic routing')
assert.equal(autoOption.dataset.autoGroupEffect, 'option')
assert.equal(autoOption.getAttribute('aria-selected'), 'true')
assert.equal(autoOption.classList.contains('bg-linear-to-r'), false)
assert.equal(autoOption.classList.contains('overflow-visible'), true)
assert.ok(autoOption.querySelector('[data-auto-group-flow-border]'))
const optionRatio = autoOption.querySelector<HTMLElement>(
'[data-auto-group-effect="ratio"]'
)
assert.ok(optionRatio)
assert.equal(optionRatio.textContent, 'Auto Ratio')
assert.ok(optionRatio.querySelector('[data-auto-group-flow-border]'))
const defaultOption = getCommandItem('User group')
assert.equal(defaultOption.hasAttribute('data-auto-group-effect'), false)
assert.equal(
defaultOption.querySelector('[data-auto-group-flow-border]'),
null
)
assert.equal(defaultOption.textContent?.includes('1x Ratio'), true)
assert.equal(
defaultOption.querySelector('[data-auto-group-effect="ratio"]'),
null
)
await act(async () => root.unmount())
container.remove()
})
test('keeps search and selection behavior while leaving normal groups unstyled', async () => {
setReducedMotion(false)
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness initialValue='auto' />))
const trigger = getTrigger(container)
await act(async () => trigger.click())
const searchInput = document.querySelector<HTMLInputElement>(
'input[placeholder="Search..."]'
)
assert.ok(searchInput)
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
domWindow.HTMLInputElement.prototype,
'value'
)?.set
assert.ok(valueSetter)
valueSetter.call(searchInput, 'vip')
searchInput.dispatchEvent(
new domWindow.Event('input', { bubbles: true }) as unknown as Event
)
})
const visibleOptions = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
]
assert.equal(
visibleOptions.some((option) =>
option.textContent?.includes('Global automatic routing')
),
false
)
const vipOption = getCommandItem('Priority group')
await act(async () => vipOption.click())
assert.equal(
container.querySelector('[data-testid="selected-group"]')?.textContent,
'vip'
)
assert.equal(trigger.getAttribute('aria-expanded'), 'false')
assert.equal(trigger.hasAttribute('data-auto-group-effect'), false)
assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null)
await act(async () => root.unmount())
container.remove()
})
test('preserves the static Auto treatment but omits moving layers for reduced motion', async () => {
setReducedMotion(true)
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness initialValue='auto' />))
const trigger = getTrigger(container)
assert.equal(trigger.dataset.autoGroupEffect, 'trigger')
assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null)
assert.ok(trigger.querySelector('[data-auto-group-effect="ratio"]'))
await act(async () => trigger.click())
const autoOption = getCommandItem('Global automatic routing')
assert.equal(autoOption.dataset.autoGroupEffect, 'option')
assert.equal(
autoOption.querySelector('[data-auto-group-flow-border]'),
null
)
assert.ok(autoOption.querySelector('[data-auto-group-effect="ratio"]'))
await act(async () => root.unmount())
container.remove()
setReducedMotion(false)
})
})
@@ -0,0 +1,371 @@
/*
Copyright (C) 2023-2026 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 assert from 'node:assert/strict'
import { after, afterEach, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'HTMLInputElement',
'HTMLFormElement',
'SVGElement',
'Node',
'Element',
'Event',
'KeyboardEvent',
'PointerEvent',
'MouseEvent',
'FocusEvent',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
const { act } = await import('react')
const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { QueryClient, QueryClientProvider } =
await import('@tanstack/react-query')
const { api } = await import('@/lib/api')
const { ApiKeysProvider } = await import('../api-keys-provider')
const { ApiKeysMutateDrawer } = await import('../api-keys-mutate-drawer')
const i18n = createInstance()
await i18n.use(initReactI18next).init({
lng: 'en',
resources: { en: { translation: {} } },
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
type ApiMethod = (url: string, data?: unknown) => Promise<{ data: unknown }>
type MockableApi = {
get: ApiMethod
post: ApiMethod
}
type RenderedDrawer = {
host: HTMLDivElement
queryClient: InstanceType<typeof QueryClient>
root: ReturnType<typeof createRoot>
}
const apiClient = api as unknown as MockableApi
const originalGet = apiClient.get
const originalPost = apiClient.post
let renderedDrawer: RenderedDrawer | null = null
function installApiFixtures(createdPayloads: Array<Record<string, unknown>>) {
apiClient.get = async (url) => {
switch (url) {
case '/api/status':
return { data: { data: { default_use_auto_group: true } } }
case '/api/user/models':
return { data: { success: true, data: [] } }
case '/api/user/self/groups':
return {
data: {
success: true,
data: {
auto: { desc: 'Automatic routing', ratio: 'auto' },
default: { desc: 'Standard access', ratio: 1 },
vip: { desc: 'Priority access', ratio: 2 },
},
},
}
case '/api/token/auto-groups':
return {
data: {
success: true,
data: { groups: ['vip', 'default'], max_count: 3 },
},
}
default:
throw new Error(`Unexpected GET ${url}`)
}
}
apiClient.post = async (url, data) => {
assert.equal(url, '/api/token/')
assert.ok(data && typeof data === 'object')
createdPayloads.push(data as Record<string, unknown>)
return { data: { success: true, data: {} } }
}
}
async function waitForCondition(
condition: () => boolean,
failureMessage: string
): Promise<void> {
if (condition()) return
await new Promise<void>((resolve, reject) => {
const observer = new MutationObserver(() => {
if (!condition()) return
clearTimeout(timeoutId)
observer.disconnect()
resolve()
})
const timeoutId = setTimeout(() => {
observer.disconnect()
reject(new Error(`${failureMessage}: ${document.body.textContent}`))
}, 1500)
observer.observe(document, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
})
})
}
async function renderCreateDrawer(): Promise<void> {
const host = document.createElement('div')
document.body.append(host)
const root = createRoot(host)
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const freshAt = Date.now() + 60_000
queryClient.setQueryData(
['status'],
{ default_use_auto_group: true },
{ updatedAt: freshAt }
)
queryClient.setQueryData(
['user-models'],
{ success: true, data: [] },
{ updatedAt: freshAt }
)
queryClient.setQueryData(
['user-groups'],
{
success: true,
data: {
auto: { desc: 'Automatic routing', ratio: 'auto' },
default: { desc: 'Standard access', ratio: 1 },
vip: { desc: 'Priority access', ratio: 2 },
},
},
{ updatedAt: freshAt }
)
queryClient.setQueryData(
['token-auto-groups'],
{
success: true,
data: { groups: ['vip', 'default'], max_count: 3 },
},
{ updatedAt: freshAt }
)
renderedDrawer = { host, queryClient, root }
await act(async () =>
root.render(
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<ApiKeysProvider>
<ApiKeysMutateDrawer open onOpenChange={() => undefined} />
</ApiKeysProvider>
</I18nextProvider>
</QueryClientProvider>
)
)
await act(async () =>
waitForCondition(() => {
const saveButton = findButton('Save changes', false)
return saveButton !== null && !saveButton.disabled
}, 'API key drawer did not finish initializing')
)
}
function findButton(text: string, required: true): HTMLButtonElement
function findButton(text: string, required: false): HTMLButtonElement | null
function findButton(text: string, required = true): HTMLButtonElement | null {
const button = [
...document.querySelectorAll<HTMLButtonElement>('button'),
].find((candidate) => candidate.textContent?.includes(text))
if (required) assert.ok(button, `Expected button containing "${text}"`)
return button ?? null
}
function getControlByLabel<T extends HTMLElement>(labelText: string): T {
const label = [...document.querySelectorAll<HTMLLabelElement>('label')].find(
(candidate) => candidate.textContent?.trim() === labelText
)
assert.ok(label, `Expected label "${labelText}"`)
assert.ok(label.htmlFor)
const control =
label.control ??
label
.closest('[data-slot="form-item"]')
?.querySelector<HTMLElement>(
'[data-slot="form-control"], input, textarea, button[role="combobox"], [role="group"]'
)
assert.ok(control)
return control as T
}
async function changeInput(input: HTMLInputElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
domWindow.HTMLInputElement.prototype,
'value'
)?.set
assert.ok(valueSetter)
valueSetter.call(input, value)
input.dispatchEvent(
new domWindow.Event('input', { bubbles: true }) as unknown as Event
)
})
}
async function selectComboboxOption(
trigger: HTMLButtonElement,
optionDescription: string
) {
await act(async () => trigger.click())
const option = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
].find((candidate) => candidate.textContent?.includes(optionDescription))
assert.ok(option, `Expected option containing "${optionDescription}"`)
await act(async () => option.click())
}
afterEach(async () => {
apiClient.get = originalGet
apiClient.post = originalPost
domWindow.localStorage.clear()
if (renderedDrawer) {
await act(async () => renderedDrawer?.root.unmount())
renderedDrawer.queryClient.clear()
renderedDrawer.host.remove()
renderedDrawer = null
}
document.body.replaceChildren()
})
after(() => {
domWindow.close()
})
describe('API keys mutate drawer Auto group integration', () => {
test('inherits the root Auto order and sends an empty override for every batch-created key', async () => {
const createdPayloads: Array<Record<string, unknown>> = []
installApiFixtures(createdPayloads)
await renderCreateDrawer()
const groupTrigger = getControlByLabel<HTMLButtonElement>('Group')
assert.equal(groupTrigger.textContent?.includes('auto'), true)
assert.equal(
document.body.textContent?.includes(
'Using the complete global Auto order (2 groups)'
),
true
)
assert.deepEqual(
[
...document.querySelectorAll('[data-slot="global-auto-order-name"]'),
].map((item) => item.textContent),
['vip', 'default']
)
assert.equal(findButton('Restore global Auto', true).disabled, true)
await changeInput(getControlByLabel<HTMLInputElement>('Name'), 'batch')
await changeInput(getControlByLabel<HTMLInputElement>('Quantity'), '2')
await act(async () => findButton('Save changes', true).click())
await act(async () =>
waitForCondition(
() => createdPayloads.length === 2,
'batch API keys were not created'
)
)
assert.equal(createdPayloads.length, 2)
assert.equal(createdPayloads[0]?.name, 'batch')
for (const payload of createdPayloads) {
assert.equal(payload.group, 'auto')
assert.deepEqual(payload.auto_groups, [])
assert.equal(payload.cross_group_retry, true)
}
})
test('preserves an unsaved custom order and mode after Auto to ordinary to Auto changes', async () => {
const createdPayloads: Array<Record<string, unknown>> = []
installApiFixtures(createdPayloads)
await renderCreateDrawer()
const autoOrderControl = getControlByLabel<HTMLElement>('Auto group order')
const addGroupTrigger = autoOrderControl.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(addGroupTrigger)
await selectComboboxOption(addGroupTrigger, 'Priority access')
assert.ok(document.querySelector('button[aria-label="Remove vip"]'))
assert.equal(
document.body.textContent?.includes('1 / 3 groups selected'),
true
)
assert.equal(findButton('Restore global Auto', true).disabled, false)
const groupTrigger = getControlByLabel<HTMLButtonElement>('Group')
await selectComboboxOption(groupTrigger, 'Standard access')
assert.equal(
document.querySelector('button[aria-label="Remove vip"]'),
null
)
await selectComboboxOption(groupTrigger, 'Automatic routing')
assert.ok(document.querySelector('button[aria-label="Remove vip"]'))
assert.equal(
document.body.textContent?.includes('1 / 3 groups selected'),
true
)
assert.equal(findButton('Restore global Auto', true).disabled, false)
await changeInput(getControlByLabel<HTMLInputElement>('Name'), 'custom')
await act(async () => findButton('Save changes', true).click())
await act(async () =>
waitForCondition(
() => createdPayloads.length === 1,
'custom-order API key was not created'
)
)
assert.deepEqual(createdPayloads[0]?.auto_groups, ['vip'])
})
})
@@ -0,0 +1,540 @@
/*
Copyright (C) 2023-2026 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 assert from 'node:assert/strict'
import { after, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLButtonElement',
'HTMLInputElement',
'SVGElement',
'Node',
'Element',
'Event',
'KeyboardEvent',
'PointerEvent',
'CustomEvent',
'MutationObserver',
'ResizeObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
const { act, useState } = await import('react')
const { createRoot } = await import('react-dom/client')
const { createInstance } = await import('i18next')
const { I18nextProvider, initReactI18next } = await import('react-i18next')
const { AutoGroupOrderEditor } = await import('../auto-group-order-editor')
const i18n = createInstance()
await i18n.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
'{{count}} / {{max}} groups selected':
'{{count}} / {{max}} groups selected',
'Add Auto group': 'Add Auto group',
'Auto group order': 'Auto group order',
'Drag {{group}} to reorder': 'Drag {{group}} to reorder',
'Inherit global Auto order': 'Inherit global Auto order',
'Maximum {{max}} groups selected': 'Maximum {{max}} groups selected',
'Move {{group}} down': 'Move {{group}} down',
'Move {{group}} up': 'Move {{group}} up',
'No available groups in the global Auto order.':
'No available groups in the global Auto order.',
'No valid custom Auto groups remain. Add a group or restore global Auto.':
'No valid custom Auto groups remain. Add a group or restore global Auto.',
'No custom groups. Saving will inherit the complete global Auto order.':
'No custom groups. Saving will inherit the complete global Auto order.',
'Remove {{group}}': 'Remove {{group}}',
'Restore global Auto': 'Restore global Auto',
Ratio: 'Ratio',
'Search...': 'Search...',
'No group found.': 'No group found.',
'Select a group': 'Select a group',
'Using the complete global Auto order ({{count}} groups)':
'Using the complete global Auto order ({{count}} groups)',
},
},
},
})
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
const globalOptions = [
{ value: 'vip', label: 'VIP', desc: 'Priority access', ratio: 3 },
{ value: 'default', label: 'Default', desc: 'Standard access', ratio: 1 },
{ value: 'team', label: 'Team', desc: 'Shared access', ratio: 2 },
]
function Harness(props: { initialGroups?: string[] }) {
const [groups, setGroups] = useState(
props.initialGroups ?? ['default', 'vip']
)
const [mode, setMode] = useState<'inherit' | 'custom'>('custom')
return (
<I18nextProvider i18n={i18n}>
<AutoGroupOrderEditor
value={groups}
mode={mode}
options={[
{ value: 'auto', label: 'auto' },
{ value: 'default', label: 'default', ratio: 1 },
{ value: 'vip', label: 'vip', ratio: 2 },
{ value: 'team', label: 'team', ratio: 3 },
]}
globalOptions={globalOptions}
maxCount={2}
onChange={(value) => {
setGroups(value.groups)
setMode(value.mode)
}}
/>
<output data-testid='order'>{groups.join(',')}</output>
<output data-testid='mode'>{mode}</output>
</I18nextProvider>
)
}
function InheritanceHarness(props: { globalOptions?: typeof globalOptions }) {
const [groups, setGroups] = useState<string[]>([])
const [mode, setMode] = useState<'inherit' | 'custom'>('inherit')
return (
<I18nextProvider i18n={i18n}>
<AutoGroupOrderEditor
value={groups}
mode={mode}
options={[{ value: 'auto', label: 'auto' }, ...globalOptions]}
globalOptions={props.globalOptions ?? globalOptions}
maxCount={2}
onChange={(value) => {
setGroups(value.groups)
setMode(value.mode)
}}
/>
<output data-testid='order'>{groups.join(',')}</output>
<output data-testid='mode'>{mode}</output>
</I18nextProvider>
)
}
function CustomEmptyHarness() {
const [groups, setGroups] = useState<string[]>([])
const [mode, setMode] = useState<'inherit' | 'custom'>('custom')
return (
<I18nextProvider i18n={i18n}>
<AutoGroupOrderEditor
value={groups}
mode={mode}
options={[{ value: 'auto', label: 'auto' }, ...globalOptions]}
globalOptions={globalOptions}
maxCount={2}
onChange={(value) => {
setGroups(value.groups)
setMode(value.mode)
}}
/>
<output data-testid='order'>{groups.join(',')}</output>
<output data-testid='mode'>{mode}</output>
</I18nextProvider>
)
}
function findButton(container: ParentNode, label: string): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`
)
assert.ok(button)
return button
}
describe('Auto group order editor', () => {
after(() => {
domWindow.close()
})
test('enforces the limit and exposes accessible reorder controls', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness />))
const addButton = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(addButton)
assert.equal(addButton.disabled, true)
assert.equal(container.textContent?.includes('2 / 2 groups selected'), true)
assert.ok(
container.querySelector('[role="group"][aria-label="Auto group order"]')
)
assert.equal(
findButton(container, 'Drag default to reorder').type,
'button'
)
await act(async () => findButton(container, 'Move default down').click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'vip,default'
)
await act(async () => {
findButton(container, 'Drag vip to reorder').dispatchEvent(
new domWindow.KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
}) as unknown as KeyboardEvent
)
})
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'default,vip'
)
await act(async () => root.unmount())
container.remove()
})
test('adds and removes groups, then restores inheritance as an empty value', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness />))
await act(async () => findButton(container, 'Remove vip').click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'default'
)
const addButton = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(addButton)
assert.equal(addButton.disabled, false)
await act(async () => addButton.click())
const teamOption = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
].find((option) => option.textContent?.includes('team'))
assert.ok(teamOption)
await act(async () => teamOption.click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'default,team'
)
assert.equal(addButton.disabled, true)
const restoreButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.includes('Restore global Auto')
)
assert.ok(restoreButton)
await act(async () => restoreButton.click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
''
)
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'inherit'
)
assert.equal(
container.textContent?.includes(
'Using the complete global Auto order (3 groups)'
),
true
)
const inheritedItems = container.querySelectorAll(
'[data-slot="global-auto-order"] > li'
)
assert.deepEqual(
[...inheritedItems].map(
(item) =>
item.querySelector('[data-slot="global-auto-order-name"]')
?.textContent
),
['VIP', 'Default', 'Team']
)
await act(async () => root.unmount())
container.remove()
})
test('shows the complete inherited order with metadata beyond the custom limit', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<InheritanceHarness />))
assert.equal(
container.textContent?.includes(
'Using the complete global Auto order (3 groups)'
),
true
)
assert.equal(
container.textContent?.includes('0 / 2 groups selected'),
false
)
const order = container.querySelector<HTMLOListElement>(
'[data-slot="global-auto-order"]'
)
assert.ok(order)
assert.equal(order.classList.contains('overflow-y-auto'), true)
assert.equal(order.classList.contains('flex-wrap'), true)
const items = [...order.querySelectorAll('li')]
assert.equal(items.length, 3)
assert.equal(
order.querySelectorAll('[data-slot="global-auto-order-connector"]')
.length,
2
)
assert.deepEqual(
items.map((item) => ({
index: item.querySelector('[data-slot="global-auto-order-index"]')
?.textContent,
name: item.querySelector('[data-slot="global-auto-order-name"]')
?.textContent,
title: item
.querySelector('[data-slot="global-auto-order-chip"]')
?.getAttribute('title'),
description: item.querySelector(
'[data-slot="global-auto-order-description"]'
)?.textContent,
ratio: item.querySelector('[data-slot="badge"]')?.textContent,
})),
[
{
index: '1',
name: 'VIP',
title: 'Priority access',
description: 'Priority access',
ratio: '3x Ratio',
},
{
index: '2',
name: 'Default',
title: 'Standard access',
description: 'Standard access',
ratio: '1x Ratio',
},
{
index: '3',
name: 'Team',
title: 'Shared access',
description: 'Shared access',
ratio: '2x Ratio',
},
]
)
for (const item of items) {
const chip = item.querySelector('[data-slot="global-auto-order-chip"]')
assert.ok(chip)
const description = item.querySelector(
'[data-slot="global-auto-order-description"]'
)
assert.ok(description)
assert.equal(description.classList.contains('sr-only'), true)
}
assert.equal(
items[0]?.querySelector('[data-slot="global-auto-order-connector"]'),
null
)
for (const item of items.slice(1)) {
const connector = item.querySelector(
'[data-slot="global-auto-order-connector"]'
)
assert.ok(connector)
assert.equal(connector.getAttribute('aria-hidden'), 'true')
}
assert.equal(container.querySelector('[aria-label^="Drag "]'), null)
assert.equal(container.querySelector('[aria-label^="Move "]'), null)
assert.equal(container.querySelector('[aria-label^="Remove "]'), null)
const restoreButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.includes('Restore global Auto')
)
assert.ok(restoreButton)
assert.equal(restoreButton.disabled, true)
await act(async () => root.unmount())
container.remove()
})
test('shows an explicit empty state when the global Auto order has no groups', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () =>
root.render(<InheritanceHarness globalOptions={[]} />)
)
assert.equal(
container.textContent?.includes(
'Using the complete global Auto order (0 groups)'
),
true
)
assert.equal(
container.textContent?.includes(
'No available groups in the global Auto order.'
),
true
)
assert.equal(
container.querySelector('[data-slot="global-auto-order"]'),
null
)
await act(async () => root.unmount())
container.remove()
})
test('keeps an empty custom order distinct from global inheritance', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<CustomEmptyHarness />))
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'custom'
)
assert.equal(
container.textContent?.includes(
'No valid custom Auto groups remain. Add a group or restore global Auto.'
),
true
)
assert.equal(
container.querySelector('[data-slot="global-auto-order"]'),
null
)
const restoreButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent?.includes('Restore global Auto')
)
assert.ok(restoreButton)
assert.equal(restoreButton.disabled, false)
await act(async () => restoreButton.click())
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'inherit'
)
assert.ok(container.querySelector('[data-slot="global-auto-order"]'))
await act(async () => root.unmount())
container.remove()
})
test('adding a group from inheritance explicitly creates a custom order', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<InheritanceHarness />))
const addButton = container.querySelector<HTMLButtonElement>(
'button[role="combobox"]'
)
assert.ok(addButton)
await act(async () => addButton.click())
const vipOption = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
].find((option) => option.textContent?.includes('VIP'))
assert.ok(vipOption)
await act(async () => vipOption.click())
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'custom'
)
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
'vip'
)
assert.equal(
container.querySelector('[data-slot="global-auto-order"]'),
null
)
await act(async () => root.unmount())
container.remove()
})
test('removing the last custom group does not silently enable inheritance', async () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => root.render(<Harness initialGroups={['default']} />))
await act(async () => findButton(container, 'Remove default').click())
assert.equal(
container.querySelector('[data-testid="order"]')?.textContent,
''
)
assert.equal(
container.querySelector('[data-testid="mode"]')?.textContent,
'custom'
)
assert.equal(
container.textContent?.includes(
'No valid custom Auto groups remain. Add a group or restore global Auto.'
),
true
)
await act(async () => root.unmount())
container.remove()
})
})
@@ -0,0 +1,90 @@
/*
Copyright (C) 2023-2026 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 { useTranslation } from 'react-i18next'
import { BadgeCell, TruncatedCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
// AutoGroupBadge,
GroupRatioBadge,
type GroupRatio,
} from './auto-group-visuals'
type ApiKeyGroupCellProps = {
crossGroupRetry: boolean
group: string
ratio?: GroupRatio
shouldReduceMotion: boolean
}
export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
const { t } = useTranslation()
if (props.group !== 'auto') {
const ratio = typeof props.ratio === 'number' ? props.ratio : undefined
return (
<TruncatedCell
className='-ml-1.5'
tooltipContent={props.group || '-'}
tooltipClassName='break-all'
>
<GroupBadge group={props.group} ratio={ratio} />
</TruncatedCell>
)
}
return (
<Tooltip>
<TooltipTrigger
render={
<BadgeCell
data-api-key-group-cell='auto'
className='gap-1.5 overflow-visible text-xs'
/>
}
>
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
{/*<AutoGroupBadge shouldReduceMotion={props.shouldReduceMotion} />*/}
<GroupRatioBadge
ratio={props.ratio}
isAuto
shouldReduceMotion={props.shouldReduceMotion}
/>
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
{t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
</span>
</TooltipContent>
</Tooltip>
)
}
@@ -20,7 +20,6 @@ import { Check, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
@@ -35,8 +34,15 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import {
AUTO_GROUP_FRAME_CLASS_NAME,
AutoGroupFlowBorder,
GroupRatioBadge,
} from './auto-group-visuals'
export type ApiKeyGroupOption = {
value: string
label: string
@@ -52,50 +58,6 @@ type ApiKeyGroupComboboxProps = {
disabled?: boolean
}
function formatGroupRatio(
ratio: ApiKeyGroupOption['ratio'],
ratioLabel: string
) {
if (ratio === undefined || ratio === null || ratio === '') return null
return `${ratio}x ${ratioLabel}`
}
function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
if (typeof ratio !== 'number') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
if (ratio > 5) {
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
}
if (ratio > 3) {
return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
}
if (ratio > 1) {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
}
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
}
function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
const { t } = useTranslation()
const label = formatGroupRatio(ratio, t('Ratio'))
if (!label) return null
return (
<Badge
variant='outline'
className={cn(
'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
getRatioBadgeClassName(ratio)
)}
>
{label}
</Badge>
)
}
export function ApiKeyGroupCombobox({
options,
value,
@@ -106,7 +68,9 @@ export function ApiKeyGroupCombobox({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const selectedOption = options.find((option) => option.value === value)
const isAutoSelected = selectedOption?.value === 'auto'
const filteredOptions = useMemo(() => {
const search = searchValue.trim().toLowerCase()
@@ -138,11 +102,22 @@ export function ApiKeyGroupCombobox({
variant='outline'
role='combobox'
aria-expanded={open}
data-auto-group-effect={isAutoSelected ? 'trigger' : undefined}
disabled={disabled}
className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
className={cn(
'border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 relative h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3',
isAutoSelected &&
cn(
AUTO_GROUP_FRAME_CLASS_NAME,
'hover:border-primary/55 data-popup-open:border-primary/55 data-popup-open:ring-primary/20'
)
)}
/>
}
>
{isAutoSelected && (
<AutoGroupFlowBorder shouldReduceMotion={shouldReduceMotion} />
)}
<span className='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
<span className='min-w-0'>
<span className='block truncate font-medium'>
@@ -155,10 +130,17 @@ export function ApiKeyGroupCombobox({
)}
</span>
<span className='hidden sm:block'>
<GroupRatioBadge ratio={selectedOption?.ratio} />
<GroupRatioBadge
ratio={selectedOption?.ratio}
isAuto={isAutoSelected}
shouldReduceMotion={shouldReduceMotion}
/>
</span>
</span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
<ChevronsUpDown
aria-hidden='true'
className='size-4 shrink-0 opacity-50'
/>
</PopoverTrigger>
<PopoverContent
className='data-closed:zoom-out-100 data-open:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--anchor-width)] overflow-hidden rounded-xl p-0 shadow-lg data-closed:duration-75 data-open:duration-100'
@@ -175,16 +157,33 @@ export function ApiKeyGroupCombobox({
<CommandList className='max-h-[360px]'>
<CommandEmpty>{t('No group found.')}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((option) => (
{filteredOptions.map((option) => {
const isAutoOption = option.value === 'auto'
return (
<CommandItem
key={option.value}
value={option.value}
data-auto-group-effect={isAutoOption ? 'option' : undefined}
onSelect={() => handleSelect(option.value)}
className='data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors'
>
<Check
className={cn(
'mt-0.5 h-4 w-4',
'data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors',
isAutoOption &&
cn(
AUTO_GROUP_FRAME_CLASS_NAME,
'border-primary/35 data-[selected=true]:border-primary/55'
)
)}
>
{isAutoOption && (
<AutoGroupFlowBorder
shouldReduceMotion={shouldReduceMotion}
/>
)}
<Check
aria-hidden='true'
className={cn(
'mt-0.5 size-4',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
@@ -198,9 +197,14 @@ export function ApiKeyGroupCombobox({
</span>
)}
</span>
<GroupRatioBadge ratio={option.ratio} />
<GroupRatioBadge
ratio={option.ratio}
isAuto={isAutoOption}
shouldReduceMotion={shouldReduceMotion}
/>
</CommandItem>
))}
)
})}
</CommandGroup>
</CommandList>
</Command>
@@ -20,8 +20,6 @@ import { useQuery } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { BadgeCell, TruncatedCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
@@ -30,6 +28,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { toIntlLocale } from '@/i18n/languages'
import { getUserGroups } from '@/lib/api'
import dayjs from '@/lib/dayjs'
@@ -38,6 +37,7 @@ import { cn } from '@/lib/utils'
import { API_KEY_STATUSES } from '../constants'
import type { ApiKey } from '../types'
import { ApiKeyGroupCell } from './api-key-group-cell'
import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
import {
ApiKeyCell,
@@ -53,16 +53,16 @@ function getQuotaProgressColor(percentage: number): string {
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
}
function useGroupRatios(): Record<string, number> {
function useGroupRatios(): Record<string, number | string> {
const { data } = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
staleTime: 0,
select: (res) => {
if (!res.success || !res.data) return {}
const ratios: Record<string, number> = {}
const ratios: Record<string, number | string> = {}
for (const [group, info] of Object.entries(res.data)) {
if (typeof info.ratio === 'number') {
if (typeof info.ratio === 'number' || typeof info.ratio === 'string') {
ratios[group] = info.ratio
}
}
@@ -76,6 +76,7 @@ function useGroupRatios(): Record<string, number> {
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
const { t, i18n } = useTranslation()
const groupRatios = useGroupRatios()
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const justNowLabel = t('Just now')
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
@@ -195,44 +196,16 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
cell: ({ row }) => {
const apiKey = row.original
const group = row.getValue('group') as string
const ratio = group && group !== 'auto' ? groupRatios[group] : undefined
if (group === 'auto') {
return (
<Tooltip>
<TooltipTrigger
render={<BadgeCell className='gap-1.5 text-xs' />}
>
<GroupBadge group='auto' />
{apiKey.cross_group_retry && (
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
<ApiKeyGroupCell
group={group}
ratio={groupRatios[group]}
crossGroupRetry={apiKey.cross_group_retry}
shouldReduceMotion={shouldReduceMotion}
/>
)}
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
{t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
</span>
</TooltipContent>
</Tooltip>
)
}
return (
<TruncatedCell
className='-ml-1.5'
tooltipContent={group || '-'}
tooltipClassName='break-all'
>
<GroupBadge group={group} ratio={ratio} />
</TruncatedCell>
)
},
size: 160,
size: 220,
meta: { mobileHidden: true },
},
{
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, KeyRound, Settings2, WalletCards } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useForm, type SubmitErrorHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -67,7 +67,12 @@ import { getUserModels, getUserGroups } from '@/lib/api'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { createApiKey, updateApiKey, getApiKey } from '../api'
import {
createApiKey,
updateApiKey,
getApiKey,
getTokenAutoGroups,
} from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
getApiKeyFormSchema,
@@ -82,6 +87,7 @@ import {
type ApiKeyGroupOption,
} from './api-key-group-combobox'
import { useApiKeys } from './api-keys-provider'
import { AutoGroupOrderEditor } from './auto-group-order-editor'
type ApiKeyMutateDrawerProps = {
open: boolean
@@ -96,10 +102,14 @@ export function ApiKeysMutateDrawer({
}: ApiKeyMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
const currentRowId = currentRow?.id
const { triggerRefresh } = useApiKeys()
const { status } = useStatus()
const { status, loading: statusLoading } = useStatus()
const [isSubmitting, setIsSubmitting] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [initializedTarget, setInitializedTarget] = useState<string | null>(
null
)
const defaultUseAutoGroup = status?.default_use_auto_group === true
// Fetch models
@@ -111,25 +121,77 @@ export function ApiKeysMutateDrawer({
})
// Fetch groups
const { data: groupsData } = useQuery({
const {
data: groupsData,
isFetched: groupsFetched,
isFetching: groupsFetching,
} = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
enabled: open,
staleTime: 0,
})
const {
data: apiKeyData,
isFetched: apiKeyFetched,
isFetching: apiKeyFetching,
} = useQuery({
queryKey: ['api-key', currentRowId],
queryFn: () => getApiKey(currentRowId ?? 0),
enabled: open && isUpdate && currentRowId !== undefined,
staleTime: 0,
})
const {
data: autoGroupsData,
isFetched: autoGroupsFetched,
isFetching: autoGroupsFetching,
} = useQuery({
queryKey: ['token-auto-groups'],
queryFn: getTokenAutoGroups,
enabled: open,
staleTime: 0,
})
const models = modelsData?.data || []
const groupsRaw = groupsData?.data || {}
const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
([key, info]) => ({
const groups = useMemo<ApiKeyGroupOption[]>(
() =>
Object.entries(groupsData?.data || {}).map(([key, info]) => ({
value: key,
label: key,
desc: info.desc || key,
ratio: info.ratio,
})
})),
[groupsData]
)
const backendHasAuto = groups.some((g) => g.value === 'auto')
const schema = getApiKeyFormSchema(t)
const availableAutoGroupNames = useMemo(
() => groups.filter((group) => group.value !== 'auto').map((g) => g.value),
[groups]
)
const globalAutoGroups = useMemo(() => {
const available = new Set(availableAutoGroupNames)
return (autoGroupsData?.data?.groups || []).filter((group) =>
available.has(group)
)
}, [autoGroupsData, availableAutoGroupNames])
const globalAutoGroupOptions = useMemo(() => {
const groupsByValue = new Map(groups.map((group) => [group.value, group]))
return globalAutoGroups.flatMap((group) => {
const option = groupsByValue.get(group)
return option ? [option] : []
})
}, [globalAutoGroups, groups])
const maxAutoGroups =
Number.isInteger(autoGroupsData?.data?.max_count) &&
Number(autoGroupsData?.data?.max_count) > 0
? Number(autoGroupsData?.data?.max_count)
: 5
const schema = useMemo(
() => getApiKeyFormSchema(t, maxAutoGroups),
[t, maxAutoGroups]
)
const form = useForm<ApiKeyFormValues>({
resolver: zodResolver(schema),
@@ -138,23 +200,69 @@ export function ApiKeysMutateDrawer({
// Load existing data when updating
useEffect(() => {
if (open && isUpdate && currentRow) {
void getApiKey(currentRow.id).then((result) => {
if (result.success && result.data) {
form.reset(transformApiKeyToFormDefaults(result.data))
if (!open) {
setInitializedTarget(null)
return
}
})
} else if (open && !isUpdate) {
if (
!groupsFetched ||
groupsFetching ||
!autoGroupsFetched ||
autoGroupsFetching
) {
return
}
if (isUpdate && (!apiKeyFetched || apiKeyFetching)) return
if (!isUpdate && statusLoading) return
const target = isUpdate && currentRow ? `update:${currentRow.id}` : 'create'
if (initializedTarget === target) return
if (isUpdate && currentRow) {
if (apiKeyData?.success && apiKeyData.data) {
form.reset(
transformApiKeyToFormDefaults(
apiKeyData.data,
availableAutoGroupNames,
maxAutoGroups
)
)
setInitializedTarget(target)
}
} else {
form.reset(
getApiKeyFormDefaultValues(defaultUseAutoGroup && backendHasAuto)
)
setInitializedTarget(target)
}
}, [open, isUpdate, currentRow, form, defaultUseAutoGroup, backendHasAuto])
}, [
open,
isUpdate,
currentRow,
form,
defaultUseAutoGroup,
statusLoading,
backendHasAuto,
groupsFetched,
groupsFetching,
autoGroupsFetched,
autoGroupsFetching,
apiKeyData,
apiKeyFetched,
apiKeyFetching,
availableAutoGroupNames,
maxAutoGroups,
initializedTarget,
])
const formTarget =
isUpdate && currentRow ? `update:${currentRow.id}` : 'create'
const isFormInitialized = initializedTarget === formTarget
const selectedGroup = form.watch('group')
// Correct group after groups load: if the form value is not in available groups, fall back
useEffect(() => {
if (groups.length === 0) return
const currentGroup = form.getValues('group')
const currentGroup = selectedGroup
if (currentGroup && !groups.some((g) => g.value === currentGroup)) {
const fallback =
groups.find((g) => g.value === 'default')?.value ??
@@ -162,10 +270,12 @@ export function ApiKeysMutateDrawer({
''
form.setValue('group', fallback)
if (currentGroup === 'auto') {
form.setValue('auto_groups', [])
form.setValue('auto_groups_mode', 'inherit')
form.setValue('cross_group_retry', false)
}
}
}, [groups, form])
}, [groups, form, selectedGroup])
const onSubmit = async (data: ApiKeyFormValues) => {
setIsSubmitting(true)
@@ -247,7 +357,7 @@ export function ApiKeysMutateDrawer({
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel })
const selectedGroup = form.watch('group')
const autoGroupsMode = form.watch('auto_groups_mode')
const unlimitedQuota = form.watch('unlimited_quota')
return (
@@ -277,6 +387,8 @@ export function ApiKeysMutateDrawer({
<form
id='api-key-form'
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
aria-busy={!isFormInitialized}
inert={!isFormInitialized || isSubmitting ? true : undefined}
className={sideDrawerFormClassName('gap-5')}
>
<SideDrawerSection>
@@ -310,7 +422,18 @@ export function ApiKeysMutateDrawer({
<ApiKeyGroupCombobox
options={groups}
value={field.value}
onValueChange={field.onChange}
onValueChange={(group) => {
field.onChange(group)
if (group === 'auto') {
form.setValue('cross_group_retry', true, {
shouldDirty: true,
})
return
}
form.setValue('cross_group_retry', false, {
shouldDirty: true,
})
}}
placeholder={t('Select a group')}
/>
</FormControl>
@@ -319,6 +442,47 @@ export function ApiKeysMutateDrawer({
)}
/>
{selectedGroup === 'auto' && (
<FormField
control={form.control}
name='auto_groups'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auto group order')}</FormLabel>
<FormDescription>
{t(
'Choose and order the groups this API key will try.'
)}
</FormDescription>
<FormControl>
<AutoGroupOrderEditor
value={field.value}
mode={autoGroupsMode}
options={groups}
globalOptions={globalAutoGroupOptions}
maxCount={maxAutoGroups}
onChange={(value) => {
form.setValue('auto_groups_mode', value.mode, {
shouldDirty: true,
shouldValidate: false,
})
form.setValue(
'auto_groups',
value.groups.slice(0, maxAutoGroups),
{
shouldDirty: true,
shouldValidate: true,
}
)
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{selectedGroup === 'auto' && (
<FormField
control={form.control}
@@ -595,7 +759,7 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
onClick={form.handleSubmit(onSubmit, onInvalid)}
disabled={isSubmitting}
disabled={!isFormInitialized || isSubmitting}
className='w-full sm:w-auto'
>
{isSubmitting ? t('Saving...') : t('Save changes')}
@@ -0,0 +1,338 @@
/*
Copyright (C) 2023-2026 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 {
ArrowDown01Icon,
ArrowRight01Icon,
ArrowUp01Icon,
Cancel01Icon,
Drag01Icon,
} from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { Reorder, useDragControls } from 'motion/react'
import {
useMemo,
type ComponentProps,
type KeyboardEvent,
type PointerEvent,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty'
import { cn } from '@/lib/utils'
import {
ApiKeyGroupCombobox,
type ApiKeyGroupOption,
} from './api-key-group-combobox'
import { GroupRatioBadge } from './auto-group-visuals'
type AutoGroupOrderEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
value: string[]
mode: 'inherit' | 'custom'
options: ApiKeyGroupOption[]
globalOptions: ApiKeyGroupOption[]
maxCount: number
onChange: (value: { groups: string[]; mode: 'inherit' | 'custom' }) => void
'data-slot'?: string
'data-form-root'?: string
}
type AutoGroupOrderItemProps = {
group: string
index: number
count: number
onMove: (index: number, direction: 'up' | 'down') => void
onRemove: (group: string) => void
}
function AutoGroupOrderItem(props: AutoGroupOrderItemProps) {
const { t } = useTranslation()
const dragControls = useDragControls()
const handleDragStart = (event: PointerEvent<HTMLButtonElement>) => {
dragControls.start(event)
}
const handleDragKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault()
props.onMove(props.index, 'up')
}
if (event.key === 'ArrowDown') {
event.preventDefault()
props.onMove(props.index, 'down')
}
}
return (
<Reorder.Item
value={props.group}
dragListener={false}
dragControls={dragControls}
className='bg-background flex items-center gap-2 rounded-lg border p-2'
>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='text-muted-foreground cursor-grab touch-none font-mono active:cursor-grabbing'
aria-label={t('Drag {{group}} to reorder', { group: props.group })}
onPointerDown={handleDragStart}
onKeyDown={handleDragKeyDown}
>
<HugeiconsIcon icon={Drag01Icon} strokeWidth={2} aria-hidden='true' />
</Button>
<span className='min-w-0 flex-1 truncate text-sm font-medium'>
{props.group}
</span>
<div className='flex shrink-0 gap-1'>
<Button
type='button'
variant='ghost'
size='icon-sm'
disabled={props.index === 0}
aria-label={t('Move {{group}} up', { group: props.group })}
onClick={() => props.onMove(props.index, 'up')}
>
<HugeiconsIcon
icon={ArrowUp01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
<Button
type='button'
variant='ghost'
size='icon-sm'
disabled={props.index === props.count - 1}
aria-label={t('Move {{group}} down', { group: props.group })}
onClick={() => props.onMove(props.index, 'down')}
>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
<Button
type='button'
variant='ghost'
size='icon-sm'
aria-label={t('Remove {{group}}', { group: props.group })}
onClick={() => props.onRemove(props.group)}
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
aria-hidden='true'
/>
</Button>
</div>
</Reorder.Item>
)
}
export function AutoGroupOrderEditor(props: AutoGroupOrderEditorProps) {
const { t } = useTranslation()
const maxCount =
Number.isInteger(props.maxCount) && props.maxCount > 0 ? props.maxCount : 5
const isInheriting = props.mode === 'inherit'
const atLimit = props.value.length >= maxCount
const candidates = useMemo(
() =>
props.options.filter(
(option) =>
option.value !== 'auto' && !props.value.includes(option.value)
),
[props.options, props.value]
)
const handleAdd = (group: string) => {
if (atLimit || props.value.includes(group)) return
props.onChange({
groups: [...props.value, group],
mode: 'custom',
})
}
const handleRemove = (group: string) => {
props.onChange({
groups: props.value.filter((item) => item !== group),
mode: 'custom',
})
}
const handleMove = (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= props.value.length) return
const next = [...props.value]
;[next[index], next[targetIndex]] = [next[targetIndex], next[index]]
props.onChange({ groups: next, mode: 'custom' })
}
return (
<div
id={props.id}
data-slot={props['data-slot']}
data-form-root={props['data-form-root']}
role='group'
tabIndex={-1}
aria-label={props['aria-label'] || t('Auto group order')}
aria-describedby={props['aria-describedby']}
aria-invalid={props['aria-invalid']}
className={cn('flex flex-col gap-3', props.className)}
>
<div className='flex items-center justify-between gap-3'>
<p className='text-muted-foreground text-xs' aria-live='polite'>
{isInheriting
? t('Using the complete global Auto order ({{count}} groups)', {
count: props.globalOptions.length,
})
: t('{{count}} / {{max}} groups selected', {
count: props.value.length,
max: maxCount,
})}
</p>
<Button
type='button'
variant='outline'
size='sm'
disabled={isInheriting}
onClick={() => {
props.onChange({ groups: [], mode: 'inherit' })
}}
>
{t('Restore global Auto')}
</Button>
</div>
<ApiKeyGroupCombobox
options={candidates}
value={undefined}
onValueChange={handleAdd}
placeholder={
atLimit
? t('Maximum {{max}} groups selected', { max: maxCount })
: t('Add Auto group')
}
disabled={atLimit || candidates.length === 0}
/>
{isInheriting && props.globalOptions.length === 0 && (
<Empty className='min-h-28 border'>
<EmptyHeader>
<EmptyTitle>{t('Inherit global Auto order')}</EmptyTitle>
<EmptyDescription>
{t('No available groups in the global Auto order.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{isInheriting && props.globalOptions.length > 0 && (
<ol
data-slot='global-auto-order'
aria-label={t('Inherit global Auto order')}
className='flex max-h-24 flex-wrap content-start gap-1.5 overflow-y-auto'
>
{props.globalOptions.map((option, index) => (
<li key={option.value} className='flex min-w-0 items-center gap-1'>
{index > 0 && (
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
aria-hidden='true'
data-slot='global-auto-order-connector'
className='text-muted-foreground size-3.5 shrink-0'
/>
)}
<span
data-slot='global-auto-order-chip'
title={option.desc}
className='bg-muted/30 flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1'
>
<span
data-slot='global-auto-order-index'
aria-hidden='true'
className='bg-primary/10 text-primary flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums'
>
{index + 1}
</span>
<span
data-slot='global-auto-order-name'
className='max-w-40 truncate text-xs font-medium'
>
{option.label}
</span>
{option.desc && (
<span
data-slot='global-auto-order-description'
className='sr-only'
>
{option.desc}
</span>
)}
<GroupRatioBadge ratio={option.ratio} />
</span>
</li>
))}
</ol>
)}
{!isInheriting && props.value.length === 0 && (
<Empty className='min-h-24 border'>
<EmptyHeader>
<EmptyTitle>{t('Auto group order')}</EmptyTitle>
<EmptyDescription>
{t(
'No valid custom Auto groups remain. Add a group or restore global Auto.'
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{!isInheriting && props.value.length > 0 && (
<Reorder.Group
axis='y'
values={props.value}
onReorder={(groups) => props.onChange({ groups, mode: 'custom' })}
className='flex flex-col gap-2'
>
{props.value.map((group, index) => (
<AutoGroupOrderItem
key={group}
group={group}
index={index}
count={props.value.length}
onMove={handleMove}
onRemove={handleRemove}
/>
))}
</Reorder.Group>
)}
</div>
)
}
@@ -0,0 +1,140 @@
/*
Copyright (C) 2023-2026 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 type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
export type GroupRatio = number | string | null | undefined
export const AUTO_GROUP_FRAME_CLASS_NAME =
'border-primary/40 relative overflow-visible border shadow-sm shadow-primary/10'
type AutoGroupFlowBorderProps = {
shouldReduceMotion: boolean
}
export function AutoGroupFlowBorder(props: AutoGroupFlowBorderProps) {
if (props.shouldReduceMotion) return null
return (
<span
aria-hidden='true'
data-auto-group-flow-border='true'
className='auto-group-flow-border pointer-events-none absolute -inset-px'
/>
)
}
type AutoGroupFrameProps = {
children: ReactNode
className?: string
effect: 'badge' | 'ratio'
shouldReduceMotion: boolean
}
export function AutoGroupFrame(props: AutoGroupFrameProps) {
return (
<span
data-auto-group-frame='true'
data-auto-group-effect={props.effect}
className={cn(
AUTO_GROUP_FRAME_CLASS_NAME,
'inline-flex max-w-full shrink-0 rounded-4xl p-px',
props.className
)}
>
<AutoGroupFlowBorder shouldReduceMotion={props.shouldReduceMotion} />
{props.children}
</span>
)
}
function getRatioBadgeClassName(ratio: GroupRatio, isAuto: boolean): string {
if (isAuto || typeof ratio !== 'number') {
return 'border-primary/30 bg-primary/10 text-primary'
}
if (ratio > 5) {
return 'border-destructive/30 bg-destructive/10 text-destructive'
}
if (ratio > 3) {
return 'border-warning/30 bg-warning/10 text-warning'
}
if (ratio > 1) {
return 'border-info/30 bg-info/10 text-info'
}
return 'border-success/30 bg-success/10 text-success'
}
type GroupRatioBadgeProps = {
isAuto?: boolean
ratio: GroupRatio
shouldReduceMotion?: boolean
}
export function GroupRatioBadge(props: GroupRatioBadgeProps) {
const { t } = useTranslation()
if (props.ratio === undefined || props.ratio === null || props.ratio === '') {
return null
}
const label =
typeof props.ratio === 'number'
? `${props.ratio}x ${t('Ratio')}`
: `${t('Auto')} ${t('Ratio')}`
const badge = (
<Badge
variant='outline'
className={cn(
'max-w-full truncate text-[10px] sm:text-xs',
getRatioBadgeClassName(props.ratio, props.isAuto === true)
)}
>
{label}
</Badge>
)
if (!props.isAuto) {
return <span className='max-w-24 shrink-0 sm:max-w-none'>{badge}</span>
}
return (
<AutoGroupFrame
effect='ratio'
shouldReduceMotion={props.shouldReduceMotion ?? false}
className='max-w-24 sm:max-w-none'
>
{badge}
</AutoGroupFrame>
)
}
export function AutoGroupBadge(props: AutoGroupFlowBorderProps) {
return (
<AutoGroupFrame
effect='badge'
shouldReduceMotion={props.shouldReduceMotion}
>
<GroupBadge group='auto' />
</AutoGroupFrame>
)
}
@@ -0,0 +1,189 @@
/*
Copyright (C) 2023-2026 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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import type { TFunction } from 'i18next'
import { apiKeySchema, type ApiKey } from '../../types'
import {
getApiKeyFormDefaultValues,
getApiKeyFormSchema,
transformApiKeyToFormDefaults,
transformFormDataToPayload,
} from '../api-key-form'
const t = ((key: string, options?: Record<string, unknown>) => {
if (options?.max !== undefined) {
return key.replace('{{max}}', String(options.max))
}
return key
}) as TFunction
const baseApiKey: ApiKey = {
id: 1,
name: 'test',
key: 'sk-test',
status: 1,
remain_quota: 0,
used_quota: 0,
unlimited_quota: true,
expired_time: -1,
created_time: 1,
accessed_time: 0,
group: 'auto',
auto_groups: null,
cross_group_retry: true,
model_limits_enabled: false,
model_limits: '',
allow_ips: '',
}
describe('API key Auto group form mapping', () => {
test('treats legacy token responses without auto_groups as inheritance', () => {
const legacyApiKey: Record<string, unknown> = { ...baseApiKey }
delete legacyApiKey.auto_groups
assert.equal(apiKeySchema.parse(legacyApiKey).auto_groups, null)
})
test('creates an Auto token that inherits the global order', () => {
const defaults = getApiKeyFormDefaultValues(true)
assert.equal(defaults.group, 'auto')
assert.equal(defaults.auto_groups_mode, 'inherit')
assert.deepEqual(defaults.auto_groups, [])
assert.deepEqual(transformFormDataToPayload(defaults).auto_groups, [])
})
test('maps omitted, null, and empty snapshots to inheritance on edit', () => {
const legacyApiKey: Record<string, unknown> = { ...baseApiKey }
delete legacyApiKey.auto_groups
const inheritedApiKeys = [
apiKeySchema.parse(legacyApiKey),
baseApiKey,
{ ...baseApiKey, auto_groups: [] },
]
for (const apiKey of inheritedApiKeys) {
const defaults = transformApiKeyToFormDefaults(
apiKey,
['default', 'vip'],
2
)
assert.equal(defaults.auto_groups_mode, 'inherit')
assert.deepEqual(defaults.auto_groups, [])
}
})
test('filters a stored snapshot before applying a lowered limit', () => {
const defaults = transformApiKeyToFormDefaults(
{
...baseApiKey,
auto_groups: ['revoked', 'vip', 'default'],
},
['default', 'vip'],
2
)
assert.equal(defaults.auto_groups_mode, 'custom')
assert.deepEqual(defaults.auto_groups, ['vip', 'default'])
})
test('keeps a fully filtered snapshot custom and rejects it until resolved', () => {
const defaults = transformApiKeyToFormDefaults(
{ ...baseApiKey, auto_groups: ['revoked'] },
['default'],
2
)
assert.equal(defaults.auto_groups_mode, 'custom')
assert.deepEqual(defaults.auto_groups, [])
const result = getApiKeyFormSchema(t, 2).safeParse(defaults)
assert.equal(result.success, false)
if (result.success) return
assert.deepEqual(result.error.issues[0]?.path, ['auto_groups'])
assert.equal(
result.error.issues[0]?.message,
'Select at least one Auto group or restore global Auto.'
)
})
test('submits a valid custom snapshot in its configured order', () => {
const custom = {
...getApiKeyFormDefaultValues(true),
auto_groups_mode: 'custom' as const,
auto_groups: ['vip', 'default'],
}
assert.deepEqual(transformFormDataToPayload(custom).auto_groups, [
'vip',
'default',
])
})
test('submits an empty array for inheritance and for non-Auto groups', () => {
const inherited = getApiKeyFormDefaultValues(true)
assert.deepEqual(transformFormDataToPayload(inherited).auto_groups, [])
const nonAuto = {
...inherited,
group: 'default',
auto_groups_mode: 'custom' as const,
auto_groups: ['vip'],
}
assert.deepEqual(transformFormDataToPayload(nonAuto).auto_groups, [])
assert.equal(transformFormDataToPayload(nonAuto).cross_group_retry, false)
})
test('rejects snapshots over the configured limit', () => {
const result = getApiKeyFormSchema(t, 1).safeParse({
...getApiKeyFormDefaultValues(true),
name: 'limited token',
auto_groups_mode: 'custom',
auto_groups: ['default', 'vip'],
})
assert.equal(result.success, false)
if (result.success) return
assert.equal(result.error.issues[0]?.path[0], 'auto_groups')
assert.equal(
result.error.issues[0]?.message,
'Select at most 1 Auto groups'
)
})
test('rejects duplicate custom groups', () => {
const result = getApiKeyFormSchema(t).safeParse({
...getApiKeyFormDefaultValues(true),
name: 'duplicate token',
auto_groups_mode: 'custom',
auto_groups: ['vip', 'vip'],
})
assert.equal(result.success, false)
if (result.success) return
assert.equal(
result.error.issues[0]?.message,
'Auto groups must not contain duplicates'
)
})
})
+60 -3
View File
@@ -22,13 +22,16 @@ import { z } from 'zod'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import { DEFAULT_GROUP } from '../constants'
import { type ApiKeyFormData, type ApiKey } from '../types'
import type { ApiKey, ApiKeyFormData } from '../types'
// ============================================================================
// Form Schema
// ============================================================================
export function getApiKeyFormSchema(t: TFunction) {
export function getApiKeyFormSchema(t: TFunction, maxAutoGroups = 5) {
const autoGroupLimit =
Number.isInteger(maxAutoGroups) && maxAutoGroups > 0 ? maxAutoGroups : 5
return z
.object({
name: z.string().min(1, t('Please enter a name')),
@@ -38,10 +41,45 @@ export function getApiKeyFormSchema(t: TFunction) {
model_limits: z.array(z.string()),
allow_ips: z.string().optional(),
group: z.string().optional(),
auto_groups_mode: z.enum(['inherit', 'custom']),
auto_groups: z.array(z.string()),
cross_group_retry: z.boolean().optional(),
tokenCount: z.number().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.group === 'auto') {
if (
data.auto_groups_mode === 'custom' &&
data.auto_groups.length === 0
) {
ctx.addIssue({
code: 'custom',
path: ['auto_groups'],
message: t(
'Select at least one Auto group or restore global Auto.'
),
})
}
if (data.auto_groups.length > autoGroupLimit) {
ctx.addIssue({
code: 'custom',
path: ['auto_groups'],
message: t('Select at most {{max}} Auto groups', {
max: autoGroupLimit,
}),
})
}
if (new Set(data.auto_groups).size !== data.auto_groups.length) {
ctx.addIssue({
code: 'custom',
path: ['auto_groups'],
message: t('Auto groups must not contain duplicates'),
})
}
}
if (data.unlimited_quota) {
return
}
@@ -73,6 +111,8 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = {
model_limits: [],
allow_ips: '',
group: DEFAULT_GROUP,
auto_groups_mode: 'inherit',
auto_groups: [],
cross_group_retry: true,
tokenCount: 1,
}
@@ -83,6 +123,8 @@ export function getApiKeyFormDefaultValues(
return {
...API_KEY_FORM_DEFAULT_VALUES,
group: defaultUseAutoGroup ? 'auto' : DEFAULT_GROUP,
auto_groups_mode: 'inherit',
auto_groups: [],
cross_group_retry: defaultUseAutoGroup,
}
}
@@ -110,6 +152,10 @@ export function transformFormDataToPayload(
model_limits: data.model_limits.join(','),
allow_ips: data.allow_ips || '',
group: data.group || '',
auto_groups:
data.group === 'auto' && data.auto_groups_mode === 'custom'
? data.auto_groups
: [],
cross_group_retry: data.group === 'auto' ? !!data.cross_group_retry : false,
}
}
@@ -118,8 +164,17 @@ export function transformFormDataToPayload(
* Transform API key data to form defaults
*/
export function transformApiKeyToFormDefaults(
apiKey: ApiKey
apiKey: ApiKey,
availableAutoGroups: string[] = [],
maxAutoGroups = 5
): ApiKeyFormValues {
const availableSet = new Set(availableAutoGroups)
const storedAutoGroups = apiKey.auto_groups ?? []
const autoGroups = storedAutoGroups
.filter((group) => availableSet.has(group))
.slice(0, Math.max(0, maxAutoGroups))
const autoGroupsMode = storedAutoGroups.length > 0 ? 'custom' : 'inherit'
return {
name: apiKey.name,
remain_quota_dollars: apiKey.unlimited_quota
@@ -135,6 +190,8 @@ export function transformApiKeyToFormDefaults(
: [],
allow_ips: apiKey.allow_ips || '',
group: apiKey.group || DEFAULT_GROUP,
auto_groups_mode: autoGroupsMode,
auto_groups: autoGroups,
cross_group_retry: !!apiKey.cross_group_retry,
tokenCount: 1,
}
+7
View File
@@ -34,6 +34,7 @@ export const apiKeySchema = z.object({
created_time: z.number(),
accessed_time: z.number(),
group: z.string().nullish().default(''),
auto_groups: z.array(z.string()).nullish().default(null),
cross_group_retry: z
.preprocess((v) => {
if (v === 1) return true
@@ -91,9 +92,15 @@ export interface ApiKeyFormData {
model_limits: string
allow_ips: string
group: string
auto_groups: string[]
cross_group_retry: boolean
}
export interface TokenAutoGroupsConfig {
groups: string[]
max_count: number
}
// ============================================================================
// Dialog Types
// ============================================================================
@@ -319,6 +319,7 @@ export function ModelMutateDrawer({
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false,
CreateCacheRatio: '',
'group_ratio_setting.group_special_usable_group': '{}',
@@ -56,6 +56,7 @@ const defaultBillingSettings: BillingSettings = {
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',
PayAddress: '',
@@ -46,6 +46,7 @@ const getGroupDefaults = (settings: BillingSettings) => ({
UserUsableGroups: settings.UserUsableGroups,
GroupGroupRatio: settings.GroupGroupRatio,
AutoGroups: settings.AutoGroups,
MaxTokenAutoGroups: settings.MaxTokenAutoGroups,
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
GroupSpecialUsableGroup:
settings['group_ratio_setting.group_special_usable_group'],
@@ -0,0 +1,40 @@
/*
Copyright (C) 2023-2026 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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { positiveIntegerSchema } from '../../utils/numeric-field'
const t = (key: string) => key
const schema = positiveIntegerSchema(t('Enter a positive integer'))
describe('per-token Auto group limit validation', () => {
test('accepts any positive integer without a product upper bound', () => {
assert.equal(schema.safeParse(1000).success, true)
})
test('rejects zero, negative, and fractional limits', () => {
for (const maxTokenAutoGroups of [0, -1, 1.5]) {
const result = schema.safeParse(maxTokenAutoGroups)
assert.equal(result.success, false)
if (result.success) continue
assert.equal(result.error.issues[0]?.message, 'Enter a positive integer')
}
})
})
@@ -43,6 +43,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Sheet,
SheetContent,
@@ -59,6 +60,7 @@ import {
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { safeJsonParse } from '../utils/json-parser'
import { safeNumberFieldProps } from '../utils/numeric-field'
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
@@ -68,6 +70,7 @@ type GroupFormValues = {
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean
GroupSpecialUsableGroup: string
}
@@ -169,6 +172,34 @@ export const GroupRatioForm = memo(function GroupRatioForm({
userUsableGroups={form.watch('UserUsableGroups')}
groupGroupRatio={form.watch('GroupGroupRatio')}
autoGroups={form.watch('AutoGroups')}
maxTokenAutoGroupsField={
<FormField
control={form.control}
name='MaxTokenAutoGroups'
render={({ field, fieldState }) => (
<FormItem data-invalid={fieldState.invalid}>
<FormLabel>
{t('Maximum custom groups per token')}
</FormLabel>
<FormControl>
<Input
{...safeNumberFieldProps(field)}
type='number'
min={1}
step={1}
aria-invalid={fieldState.invalid}
/>
</FormControl>
<FormDescription>
{t(
'Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
}
groupSpecialUsableGroup={form.watch('GroupSpecialUsableGroup')}
onChange={(field, value) =>
handleFieldChange(field as keyof GroupFormValues, value)
@@ -339,6 +370,31 @@ export const GroupRatioForm = memo(function GroupRatioForm({
)}
/>
<FormField
control={form.control}
name='MaxTokenAutoGroups'
render={({ field, fieldState }) => (
<FormItem data-invalid={fieldState.invalid}>
<FormLabel>{t('Maximum custom groups per token')}</FormLabel>
<FormControl>
<Input
{...safeNumberFieldProps(field)}
type='number'
min={1}
step={1}
aria-invalid={fieldState.invalid}
/>
</FormControl>
<FormDescription>
{t(
'Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='GroupSpecialUsableGroup'
@@ -24,7 +24,14 @@ import {
Plus,
Trash2,
} from 'lucide-react'
import { useState, useMemo, useEffect, useCallback, memo } from 'react'
import {
useState,
useMemo,
useEffect,
useCallback,
memo,
type ReactNode,
} from 'react'
import { useTranslation } from 'react-i18next'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
@@ -76,6 +83,7 @@ type GroupRatioVisualEditorProps = {
userUsableGroups: string
groupGroupRatio: string
autoGroups: string
maxTokenAutoGroupsField: ReactNode
groupSpecialUsableGroup: string
onChange: (field: string, value: string) => void
}
@@ -257,6 +265,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
userUsableGroups,
groupGroupRatio,
autoGroups,
maxTokenAutoGroupsField,
groupSpecialUsableGroup,
onChange,
}: GroupRatioVisualEditorProps) {
@@ -351,6 +360,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
</CardHeader>
<CardContent>
<div className='space-y-4'>
{maxTokenAutoGroupsField}
<GroupNameSelect
options={autoGroupCandidates}
value={null}
@@ -60,6 +60,7 @@ const defaultModelSettings: ModelSettings = {
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',
RetryTimes: 0,
@@ -31,6 +31,7 @@ import { resetModelRatios } from '../api'
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { positiveIntegerSchema } from '../utils/numeric-field'
import { GroupRatioForm } from './group-ratio-form'
import { ModelRatioForm } from './model-ratio-form'
import { ToolPriceSettings } from './tool-price-settings'
@@ -130,6 +131,7 @@ const createGroupSchema = (t: Translate) =>
parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of group identifiers',
}),
MaxTokenAutoGroups: positiveIntegerSchema(t('Enter a positive integer')),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: createJsonStringField(t),
})
@@ -204,6 +206,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
MaxTokenAutoGroups: groupDefaults.MaxTokenAutoGroups,
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
@@ -290,6 +293,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
MaxTokenAutoGroups: groupDefaults.MaxTokenAutoGroups,
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
@@ -360,6 +364,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(values.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio),
AutoGroups: normalizeJsonString(values.AutoGroups),
MaxTokenAutoGroups: values.MaxTokenAutoGroups,
DefaultUseAutoGroup: values.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
values.GroupSpecialUsableGroup
@@ -382,6 +387,8 @@ export function RatioSettingsCard({
const apiKey = apiKeyMap[key] || key
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
groupNormalizedDefaults.current = normalized
},
[updateOption]
)
@@ -223,6 +223,7 @@ export type ModelSettings = {
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string
RetryTimes: number
@@ -277,6 +278,7 @@ export type BillingSettings = {
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string
PayAddress: string
@@ -22,6 +22,11 @@ import type {
FieldPath,
FieldValues,
} from 'react-hook-form'
import { z } from 'zod'
export function positiveIntegerSchema(message: string) {
return z.number().int(message).positive(message)
}
/**
* Props produced by {@link safeNumberFieldProps} for a native
+24 -3
View File
@@ -31,6 +31,7 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "{{category}} Models",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} completed",
"{{count}} / {{max}} groups selected": "{{count}} / {{max}} groups selected",
"{{count}} announcements will be removed from the list.": "{{count}} announcements will be removed from the list.",
"{{count}} API shortcuts will be removed from the list.": "{{count}} API shortcuts will be removed from the list.",
"{{count}} channel(s) deleted": "{{count}} channel(s) deleted",
@@ -169,6 +170,7 @@
"Add API": "Add API",
"Add API Shortcut": "Add API Shortcut",
"Add auto group": "Add auto group",
"Add Auto group": "Add Auto group",
"Add chat preset": "Add chat preset",
"Add condition": "Add condition",
"Add Condition": "Add Condition",
@@ -503,6 +505,8 @@
"Auto Disabled": "Auto Disabled",
"Auto group behavior": "Auto group behavior",
"Auto Group Chain": "Auto Group Chain",
"Auto group order": "Auto group order",
"Auto groups must not contain duplicates": "Auto groups must not contain duplicates",
"Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.",
"Auto refresh": "Auto refresh",
"Auto Sync Upstream Models": "Auto Sync Upstream Models",
@@ -799,6 +803,7 @@
"Chinese": "Chinese",
"Choose a username": "Choose a username",
"Choose an amount and payment method": "Choose an amount and payment method",
"Choose and order the groups this API key will try.": "Choose and order the groups this API key will try.",
"Choose between default expanded, compact icon-only, or full layout mode": "Choose between default expanded, compact icon-only, or full layout mode",
"Choose between inset, floating, or standard sidebar layout": "Choose between inset, floating, or standard sidebar layout",
"Choose between left-to-right or right-to-left site direction": "Choose between left-to-right or right-to-left site direction",
@@ -1266,6 +1271,7 @@
"Default time granularity": "Default time granularity",
"Default to auto groups": "Default to auto groups",
"Default TTL (seconds)": "Default TTL (seconds)",
"Defaults to \"OIDC\" if left blank": "Defaults to \"OIDC\" if left blank",
"Defaults to the wallet page when empty": "Defaults to the wallet page when empty",
"Define API endpoints for this model (JSON format)": "Define API endpoints for this model (JSON format)",
"Define endpoint mappings for each provider.": "Define endpoint mappings for each provider.",
@@ -1443,6 +1449,7 @@
"Downgrade to pre-purchase group": "Downgrade to pre-purchase group",
"Downgrade to this group after the subscription expires": "Downgrade to this group after the subscription expires",
"Download": "Download",
"Drag {{group}} to reorder": "Drag {{group}} to reorder",
"Draw": "Draw",
"Drawing": "Drawing",
"Drawing logs": "Drawing logs",
@@ -1465,6 +1472,7 @@
"e.g. 8 means 1 USD = 8 units": "e.g. 8 means 1 USD = 8 units",
"e.g. Basic Plan": "e.g. Basic Plan",
"e.g. Clean tool parameters to avoid upstream validation errors": "e.g. Clean tool parameters to avoid upstream validation errors",
"e.g. Company SSO": "e.g. Company SSO",
"e.g. example.com": "e.g. example.com",
"e.g. gpt-4o, gemini-2.5-flash": "e.g. gpt-4o, gemini-2.5-flash",
"e.g. llama3.1:8b": "e.g. llama3.1:8b",
@@ -1580,9 +1588,6 @@
"Enable LinuxDO OAuth": "Enable LinuxDO OAuth",
"Enable model performance metrics": "Enable model performance metrics",
"Enable OIDC": "Enable OIDC",
"OIDC Display Name": "OIDC Display Name",
"e.g. Company SSO": "e.g. Company SSO",
"Defaults to \"OIDC\" if left blank": "Defaults to \"OIDC\" if left blank",
"Enable or disable this channel": "Enable or disable this channel",
"Enable or disable this model": "Enable or disable this model",
"Enable Passkey": "Enable Passkey",
@@ -1633,6 +1638,7 @@
"Enter 6-digit code": "Enter 6-digit code",
"Enter a name": "Enter a name",
"Enter a new name": "Enter a new name",
"Enter a positive integer": "Enter a positive integer",
"Enter a positive or negative amount to adjust the quota": "Enter a positive or negative amount to adjust the quota",
"Enter a react-icons component name. Invalid names show no icon.": "Enter a react-icons component name. Invalid names show no icon.",
"Enter a valid email or leave blank": "Enter a valid email or leave blank",
@@ -2312,6 +2318,7 @@
"Incomplete": "Incomplete",
"Increased user quota by {{quota}}": "Increased user quota by {{quota}}",
"Index": "Index",
"Inherit global Auto order": "Inherit global Auto order",
"Initial quota given to new users": "Initial quota given to new users",
"Initial quota given to new users ({{formattedQuota}})": "Initial quota given to new users ({{formattedQuota}})",
"Initialization failed, please try again.": "Initialization failed, please try again.",
@@ -2489,6 +2496,7 @@
"Limit Reached": "Limit Reached",
"Limit which models can be used with this key": "Limit which models can be used with this key",
"Limited": "Limited",
"Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.",
"Limits token selection to a probability mass": "Limits token selection to a probability mass",
"LingYiWanWu": "LingYiWanWu",
"Link to your documentation site": "Link to your documentation site",
@@ -2603,10 +2611,12 @@
"Max successful requests": "Max successful requests",
"Max Successful Requests": "Max Successful Requests",
"Max Tokens": "Max Tokens",
"Maximum {{max}} groups selected": "Maximum {{max}} groups selected",
"Maximum 1000 characters. Supports Markdown and HTML.": "Maximum 1000 characters. Supports Markdown and HTML.",
"Maximum 200 characters": "Maximum 200 characters",
"Maximum 500 characters. Supports Markdown and HTML.": "Maximum 500 characters. Supports Markdown and HTML.",
"Maximum check-in quota": "Maximum check-in quota",
"Maximum custom groups per token": "Maximum custom groups per token",
"Maximum input window": "Maximum input window",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.",
"Maximum number of tokens in the response": "Maximum number of tokens in the response",
@@ -2757,6 +2767,8 @@
"More...": "More...",
"Most-used models in the selected period and category": "Most-used models in the selected period and category",
"Move": "Move",
"Move {{group}} down": "Move {{group}} down",
"Move {{group}} up": "Move {{group}} up",
"Move a request header": "Move a request header",
"Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance",
"Move fallback to end": "Move fallback to end",
@@ -2858,6 +2870,7 @@
"No app usage data available for this model.": "No app usage data available for this model.",
"No apps match the selected filters": "No apps match the selected filters",
"No Auth": "No Auth",
"No available groups in the global Auto order.": "No available groups in the global Auto order.",
"No available models": "No available models",
"No available Web chat links": "No available Web chat links",
"No backup": "No backup",
@@ -2882,6 +2895,7 @@
"No console output": "No console output",
"No containers": "No containers",
"No content to copy": "No content to copy",
"No custom groups. Saving will inherit the complete global Auto order.": "No custom groups. Saving will inherit the complete global Auto order.",
"No custom OAuth providers configured yet.": "No custom OAuth providers configured yet.",
"No data": "No data",
"No Data": "No Data",
@@ -3006,6 +3020,7 @@
"No users": "No users",
"No users available. Try adjusting your search or filters.": "No users available. Try adjusting your search or filters.",
"No Users Found": "No Users Found",
"No valid custom Auto groups remain. Add a group or restore global Auto.": "No valid custom Auto groups remain. Add a group or restore global Auto.",
"No vendor data available": "No vendor data available",
"No X Found": "No X Found",
"Node": "Node",
@@ -3081,6 +3096,7 @@
"OIDC configuration fetched successfully": "OIDC configuration fetched successfully",
"OIDC discovery can fill the endpoint fields automatically when the provider supports it.": "OIDC discovery can fill the endpoint fields automatically when the provider supports it.",
"OIDC discovery URL. Click \"Auto-discover\" to fetch endpoints automatically.": "OIDC discovery URL. Click \"Auto-discover\" to fetch endpoints automatically.",
"OIDC Display Name": "OIDC Display Name",
"OIDC endpoints discovered successfully": "OIDC endpoints discovered successfully",
"Old Format Template": "Old Format Template",
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.",
@@ -3725,6 +3741,7 @@
"Remaining:": "Remaining:",
"Remark": "Remark",
"Remove": "Remove",
"Remove {{group}}": "Remove {{group}}",
"Remove {{value}}": "Remove {{value}}",
"Remove ${{amount}}": "Remove ${{amount}}",
"Remove all log entries created before the selected timestamp.": "Remove all log entries created before the selected timestamp.",
@@ -3860,6 +3877,7 @@
"Response time: {{duration}}": "Response time: {{duration}}",
"Responses API Version": "Responses API Version",
"Restore defaults": "Restore defaults",
"Restore global Auto": "Restore global Auto",
"Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)",
"Result": "Result",
"Retain last N days": "Retain last N days",
@@ -4051,8 +4069,10 @@
"Select All Visible": "Select All Visible",
"Select an operation mode and enter the amount": "Select an operation mode and enter the amount",
"Select announcement type": "Select announcement type",
"Select at least one Auto group or restore global Auto.": "Select at least one Auto group or restore global Auto.",
"Select at least one field to overwrite.": "Select at least one field to overwrite.",
"Select at least one target model": "Select at least one target model",
"Select at most {{max}} Auto groups": "Select at most {{max}} Auto groups",
"Select body font": "Select body font",
"Select border radius": "Select border radius",
"Select channel type": "Select channel type",
@@ -4994,6 +5014,7 @@
"Users of vip, when billed as premium, pay ratio": "Users of vip, when billed as premium, pay ratio",
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.",
"uses": "uses",
"Using the complete global Auto order ({{count}} groups)": "Using the complete global Auto order ({{count}} groups)",
"Validity": "Validity",
"Validity Period": "Validity Period",
"Value": "Value",
+24 -3
View File
@@ -31,6 +31,7 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Modèles {{category}}",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} terminé(s)",
"{{count}} / {{max}} groups selected": "{{count}} groupes sélectionnés sur {{max}}",
"{{count}} announcements will be removed from the list.": "{{count}} annonces seront retirées de la liste.",
"{{count}} API shortcuts will be removed from the list.": "{{count}} raccourcis API seront retirés de la liste.",
"{{count}} channel(s) deleted": "{{count}} canal(canaux) supprimé(s)",
@@ -169,6 +170,7 @@
"Add API": "Ajouter une API",
"Add API Shortcut": "Ajouter un raccourci API",
"Add auto group": "Ajouter un groupe automatique",
"Add Auto group": "Ajouter un groupe Auto",
"Add chat preset": "Ajouter un préréglage de chat",
"Add condition": "Ajouter une condition",
"Add Condition": "Ajouter une condition",
@@ -503,6 +505,8 @@
"Auto Disabled": "Désactivé automatiquement",
"Auto group behavior": "Comportement du groupe auto",
"Auto Group Chain": "Chaîne de groupes automatique",
"Auto group order": "Ordre des groupes Auto",
"Auto groups must not contain duplicates": "Les groupes Auto ne doivent pas contenir de doublons",
"Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "Le mode Auto négocie HTTP/2 lorsque cest disponible. HTTP/1.1 force plusieurs connexions keep-alive en concurrence.",
"Auto refresh": "Actualisation automatique",
"Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont",
@@ -799,6 +803,7 @@
"Chinese": "Chinois",
"Choose a username": "Choisir un nom d'utilisateur",
"Choose an amount and payment method": "Choisir un montant et un mode de paiement",
"Choose and order the groups this API key will try.": "Sélectionnez et ordonnez les groupes que cette clé API essaiera.",
"Choose between default expanded, compact icon-only, or full layout mode": "Choisissez entre le mode d'affichage étendu par défaut, compact (icône uniquement) ou complet",
"Choose between inset, floating, or standard sidebar layout": "Choisissez entre la disposition de la barre latérale intégrée, flottante ou standard",
"Choose between left-to-right or right-to-left site direction": "Choisissez entre la direction du site de gauche à droite ou de droite à gauche",
@@ -1266,6 +1271,7 @@
"Default time granularity": "Granularité temporelle par défaut",
"Default to auto groups": "Par défaut aux groupes automatiques",
"Default TTL (seconds)": "TTL par défaut (secondes)",
"Defaults to \"OIDC\" if left blank": "Par défaut « OIDC » si laissé vide",
"Defaults to the wallet page when empty": "Si vide, la page portefeuille est utilisée par défaut",
"Define API endpoints for this model (JSON format)": "Définir les points de terminaison API pour ce modèle (format JSON)",
"Define endpoint mappings for each provider.": "Définissez les mappages d'endpoints pour chaque fournisseur.",
@@ -1443,6 +1449,7 @@
"Downgrade to pre-purchase group": "Rétrograder vers le groupe d'avant l'achat",
"Downgrade to this group after the subscription expires": "Rétrograder vers ce groupe après l'expiration de l'abonnement",
"Download": "Télécharger",
"Drag {{group}} to reorder": "Faites glisser {{group}} pour réorganiser",
"Draw": "Dessin",
"Drawing": "Dessin",
"Drawing logs": "Journaux de dessin",
@@ -1465,6 +1472,7 @@
"e.g. 8 means 1 USD = 8 units": "par ex. 8 signifie 1 USD = 8 unités",
"e.g. Basic Plan": "ex. Plan de base",
"e.g. Clean tool parameters to avoid upstream validation errors": "ex. Nettoyer les paramètres d'outils pour éviter les erreurs de validation en amont",
"e.g. Company SSO": "ex. SSO de l'entreprise",
"e.g. example.com": "par ex. example.com",
"e.g. gpt-4o, gemini-2.5-flash": "p. ex. gpt-4o, gemini-2.5-flash",
"e.g. llama3.1:8b": "p. ex. llama3.1:8b",
@@ -1580,9 +1588,6 @@
"Enable LinuxDO OAuth": "Activer LinuxDO OAuth",
"Enable model performance metrics": "Activer les indicateurs de performance des modèles",
"Enable OIDC": "Activer OIDC",
"OIDC Display Name": "Nom d'affichage OIDC",
"e.g. Company SSO": "ex. SSO de l'entreprise",
"Defaults to \"OIDC\" if left blank": "Par défaut « OIDC » si laissé vide",
"Enable or disable this channel": "Activer ou désactiver ce canal",
"Enable or disable this model": "Activer ou désactiver ce modèle",
"Enable Passkey": "Activer Passkey",
@@ -1633,6 +1638,7 @@
"Enter 6-digit code": "Saisir le code à 6 chiffres",
"Enter a name": "Saisir un nom",
"Enter a new name": "Entrez un nouveau nom",
"Enter a positive integer": "Saisissez un entier positif",
"Enter a positive or negative amount to adjust the quota": "Saisir un montant positif ou négatif pour ajuster le quota",
"Enter a react-icons component name. Invalid names show no icon.": "Saisissez le nom dun composant react-icons. Les noms invalides naffichent aucune icône.",
"Enter a valid email or leave blank": "Entrez un e-mail valide ou laissez vide",
@@ -2312,6 +2318,7 @@
"Incomplete": "Incomplet",
"Increased user quota by {{quota}}": "Quota de l'utilisateur augmenté de {{quota}}",
"Index": "Index",
"Inherit global Auto order": "Hériter de lordre Auto global",
"Initial quota given to new users": "Quota initial donné aux nouveaux utilisateurs",
"Initial quota given to new users ({{formattedQuota}})": "Quota initial donné aux nouveaux utilisateurs ({{formattedQuota}})",
"Initialization failed, please try again.": "L'initialisation a échoué, veuillez réessayer.",
@@ -2489,6 +2496,7 @@
"Limit Reached": "Limite atteinte",
"Limit which models can be used with this key": "Limiter les modèles pouvant être utilisés avec cette clé",
"Limited": "Limité",
"Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "Limite uniquement les instantanés Auto propres aux jetons. Lhéritage Auto global reste illimité.",
"Limits token selection to a probability mass": "Limite la sélection des tokens par masse de probabilité",
"LingYiWanWu": "LingYiWanWu",
"Link to your documentation site": "Lien vers votre site de documentation",
@@ -2603,10 +2611,12 @@
"Max successful requests": "Nombre max de requêtes réussies",
"Max Successful Requests": "Max Requêtes réussies",
"Max Tokens": "Tokens max.",
"Maximum {{max}} groups selected": "Maximum de {{max}} groupes atteint",
"Maximum 1000 characters. Supports Markdown and HTML.": "Maximum 1000 caractères. Prend en charge Markdown et HTML.",
"Maximum 200 characters": "Maximum 200 caractères",
"Maximum 500 characters. Supports Markdown and HTML.": "Maximum 500 caractères. Prend en charge Markdown et HTML.",
"Maximum check-in quota": "Quota maximum de connexion",
"Maximum custom groups per token": "Nombre maximal de groupes personnalisés par jeton",
"Maximum input window": "Fenêtre d'entrée maximale",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Nombre maximum de jetons que chaque utilisateur peut créer. Par défaut 1000. Une valeur trop élevée peut affecter les performances.",
"Maximum number of tokens in the response": "Nombre maximum de jetons dans la réponse",
@@ -2757,6 +2767,8 @@
"More...": "Plus...",
"Most-used models in the selected period and category": "Modèles les plus utilisés dans la période et catégorie choisies",
"Move": "Déplacer",
"Move {{group}} down": "Déplacer {{group}} vers le bas",
"Move {{group}} up": "Déplacer {{group}} vers le haut",
"Move a request header": "Déplacer un en-tête de requête",
"Move affiliate rewards to your main balance": "Transférer les récompenses d'affiliation vers votre solde principal",
"Move fallback to end": "Mettre le repli à la fin",
@@ -2858,6 +2870,7 @@
"No app usage data available for this model.": "Aucune donnée d'utilisation d'application n'est disponible pour ce modèle.",
"No apps match the selected filters": "Aucune application ne correspond aux filtres",
"No Auth": "Sans auth",
"No available groups in the global Auto order.": "Aucun groupe disponible dans lordre Auto global.",
"No available models": "Aucun modèle disponible",
"No available Web chat links": "Aucun lien de chat Web disponible",
"No backup": "Pas de sauvegarde",
@@ -2882,6 +2895,7 @@
"No console output": "Aucune sortie console",
"No containers": "Aucun conteneur",
"No content to copy": "Aucun contenu à copier",
"No custom groups. Saving will inherit the complete global Auto order.": "Aucun groupe personnalisé. Après lenregistrement, lordre Auto global complet sera hérité.",
"No custom OAuth providers configured yet.": "Aucun fournisseur OAuth personnalisé configuré pour le moment.",
"No data": "Aucune donnée",
"No Data": "Aucune donnée",
@@ -3006,6 +3020,7 @@
"No users": "Aucun utilisateur",
"No users available. Try adjusting your search or filters.": "Aucun utilisateur disponible. Essayez d'ajuster votre recherche ou vos filtres.",
"No Users Found": "Aucun utilisateur trouvé",
"No valid custom Auto groups remain. Add a group or restore global Auto.": "Aucun groupe Auto personnalisé valide ne subsiste. Ajoutez un groupe ou restaurez lAuto global.",
"No vendor data available": "Aucune donnée de fournisseur disponible",
"No X Found": "Aucun X trouvé",
"Node": "Nœud",
@@ -3081,6 +3096,7 @@
"OIDC configuration fetched successfully": "Configuration OIDC récupérée avec succès",
"OIDC discovery can fill the endpoint fields automatically when the provider supports it.": "La découverte OIDC peut remplir automatiquement les champs de point de terminaison lorsque le fournisseur la prend en charge.",
"OIDC discovery URL. Click \"Auto-discover\" to fetch endpoints automatically.": "URL de découverte OIDC. Cliquez sur \"Découverte automatique\" pour récupérer les points de terminaison automatiquement.",
"OIDC Display Name": "Nom d'affichage OIDC",
"OIDC endpoints discovered successfully": "Points de terminaison OIDC découverts avec succès",
"Old Format Template": "Modèle Ancien Format",
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "Ancien format : Remplacement direct. Nouveau format : Prend en charge le jugement conditionnel et les opérations JSON personnalisées.",
@@ -3725,6 +3741,7 @@
"Remaining:": "Restant :",
"Remark": "Remarque",
"Remove": "Supprimer",
"Remove {{group}}": "Supprimer {{group}}",
"Remove {{value}}": "Retirer {{value}}",
"Remove ${{amount}}": "Supprimer ${{amount}}",
"Remove all log entries created before the selected timestamp.": "Supprimer toutes les entrées de journal créées avant l'horodatage sélectionné.",
@@ -3860,6 +3877,7 @@
"Response time: {{duration}}": "Temps de réponse : {{duration}}",
"Responses API Version": "Version de l'API des réponses",
"Restore defaults": "Restaurer les paramètres par défaut",
"Restore global Auto": "Restaurer lAuto global",
"Restrict user model request frequency (may impact high concurrency performance)": "Restreindre la fréquence des requêtes du modèle utilisateur (peut impacter les performances en cas de forte concurrence)",
"Result": "Résultat",
"Retain last N days": "Conserver les N derniers jours",
@@ -4051,8 +4069,10 @@
"Select All Visible": "Sélectionner tout ce qui est visible",
"Select an operation mode and enter the amount": "Sélectionnez un mode d'opération et entrez le montant",
"Select announcement type": "Sélectionner le type d'annonce",
"Select at least one Auto group or restore global Auto.": "Sélectionnez au moins un groupe Auto ou restaurez lAuto global.",
"Select at least one field to overwrite.": "Sélectionnez au moins un champ à écraser.",
"Select at least one target model": "Sélectionnez au moins un modèle cible",
"Select at most {{max}} Auto groups": "Sélectionnez au maximum {{max}} groupes Auto",
"Select body font": "Sélectionner la police du corps de texte",
"Select border radius": "Sélectionner le rayon de bordure",
"Select channel type": "Sélectionner le type de canal",
@@ -4994,6 +5014,7 @@
"Users of vip, when billed as premium, pay ratio": "Les utilisateurs de vip, facturés sous premium, paient le taux",
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Les utilisateurs ne voient que les groupes marqués comme sélectionnables. Les groupes non sélectionnables peuvent toujours être attribués par les administrateurs.",
"uses": "utilisations",
"Using the complete global Auto order ({{count}} groups)": "Utilisation de lordre Auto global complet ({{count}} groupes)",
"Validity": "Validité",
"Validity Period": "Période de validité",
"Value": "Valeur",
+24 -3
View File
@@ -31,6 +31,7 @@
"{\"original-model\": \"replacement-model\"}": "{\" original - model \":\" replacement - model \"}",
"{{category}} Models": "{{category}} モデル",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} 完了",
"{{count}} / {{max}} groups selected": "{{count}} / {{max}} グループを選択済み",
"{{count}} announcements will be removed from the list.": "{{count}} 件のお知らせがリストから削除されます。",
"{{count}} API shortcuts will be removed from the list.": "{{count}} 件の API ショートカットがリストから削除されます。",
"{{count}} channel(s) deleted": "{{count}} 個のチャネルを削除しました",
@@ -169,6 +170,7 @@
"Add API": "API追加",
"Add API Shortcut": "API ショートカットを追加",
"Add auto group": "自動グループを追加",
"Add Auto group": "Auto グループを追加",
"Add chat preset": "チャットプリセットを追加",
"Add condition": "条件を追加",
"Add Condition": "条件を追加",
@@ -503,6 +505,8 @@
"Auto Disabled": "自動無効化",
"Auto group behavior": "auto グループの動作",
"Auto Group Chain": "自動グループチェーン",
"Auto group order": "Auto グループの順序",
"Auto groups must not contain duplicates": "Auto グループを重複させることはできません",
"Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "自動は利用可能な場合に HTTP/2 を交渉します。HTTP/1.1 は同時実行時に複数のキープアライブ接続を使用します。",
"Auto refresh": "自動更新",
"Auto Sync Upstream Models": "アップストリームモデルの自動同期",
@@ -799,6 +803,7 @@
"Chinese": "中国語",
"Choose a username": "ユーザー名を選択",
"Choose an amount and payment method": "金額と支払い方法を選択してください",
"Choose and order the groups this API key will try.": "この API キーが試行するグループを選択して並べ替えます。",
"Choose between default expanded, compact icon-only, or full layout mode": "デフォルトの展開表示、コンパクトなアイコンのみ、またはフルレイアウトモードから選択します",
"Choose between inset, floating, or standard sidebar layout": "インセット、フローティング、または標準のサイドバーレイアウトから選択します",
"Choose between left-to-right or right-to-left site direction": "左から右、または右から左のサイトの方向を選択します",
@@ -1266,6 +1271,7 @@
"Default time granularity": "デフォルトの時間粒度",
"Default to auto groups": "デフォルトで自動グループ化",
"Default TTL (seconds)": "デフォルト TTL(秒)",
"Defaults to \"OIDC\" if left blank": "空欄の場合は「OIDC」がデフォルトで表示されます",
"Defaults to the wallet page when empty": "空欄の場合はウォレットページを既定にします",
"Define API endpoints for this model (JSON format)": "このモデルのAPIエンドポイントを定義します (JSON形式)",
"Define endpoint mappings for each provider.": "各プロバイダーごとにエンドポイントのマッピングを定義してください。",
@@ -1443,6 +1449,7 @@
"Downgrade to pre-purchase group": "購入前のグループにダウングレード",
"Downgrade to this group after the subscription expires": "サブスクリプションの有効期限が切れた後、このグループにダウングレードします",
"Download": "ダウンロード",
"Drag {{group}} to reorder": "{{group}} をドラッグして並べ替え",
"Draw": "描画",
"Drawing": "画像生成",
"Drawing logs": "描画ログ",
@@ -1465,6 +1472,7 @@
"e.g. 8 means 1 USD = 8 units": "例: 8 は 1 USD = 8 単位 を意味します",
"e.g. Basic Plan": "例:ベーシックプラン",
"e.g. Clean tool parameters to avoid upstream validation errors": "例:ツールパラメータを整理して上流の検証エラーを回避",
"e.g. Company SSO": "例:会社の SSO",
"e.g. example.com": "例: example.com",
"e.g. gpt-4o, gemini-2.5-flash": "例: gpt-4o, gemini-2.5-flash",
"e.g. llama3.1:8b": "例: llama3.1:8b",
@@ -1580,9 +1588,6 @@
"Enable LinuxDO OAuth": "LinuxDO OAuthを有効にする",
"Enable model performance metrics": "モデル性能メトリクスを有効化",
"Enable OIDC": "OIDCを有効にする",
"OIDC Display Name": "OIDC 表示名",
"e.g. Company SSO": "例:会社の SSO",
"Defaults to \"OIDC\" if left blank": "空欄の場合は「OIDC」がデフォルトで表示されます",
"Enable or disable this channel": "このチャネルを有効または無効にする",
"Enable or disable this model": "このモデルを有効または無効にする",
"Enable Passkey": "Passkeyを有効にする",
@@ -1633,6 +1638,7 @@
"Enter 6-digit code": "6桁のコードを入力",
"Enter a name": "名前を入力",
"Enter a new name": "新しい名前を入力してください",
"Enter a positive integer": "正の整数を入力してください",
"Enter a positive or negative amount to adjust the quota": "クォータを調整するために正または負の値を入力してください",
"Enter a react-icons component name. Invalid names show no icon.": "react-icons のコンポーネント名を入力してください。無効な名前はアイコンを表示しません。",
"Enter a valid email or leave blank": "有効なメールアドレスを入力するか空白にしてください",
@@ -2312,6 +2318,7 @@
"Incomplete": "未完了",
"Increased user quota by {{quota}}": "ユーザーのクォータを {{quota}} 増やしました",
"Index": "インデックス",
"Inherit global Auto order": "グローバル Auto 順序を継承",
"Initial quota given to new users": "新規ユーザーに付与される初期クォータ",
"Initial quota given to new users ({{formattedQuota}})": "新規ユーザーに付与される初期クォータ({{formattedQuota}}",
"Initialization failed, please try again.": "初期化に失敗しました。もう一度お試しください。",
@@ -2489,6 +2496,7 @@
"Limit Reached": "上限に達しました",
"Limit which models can be used with this key": "このキーで使用できるモデルを制限する",
"Limited": "制限",
"Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "トークン固有の Auto スナップショットだけを制限します。グローバル Auto の継承には上限がありません。",
"Limits token selection to a probability mass": "確率質量でトークン選択を制限します",
"LingYiWanWu": "LingYiWanWu",
"Link to your documentation site": "ドキュメントサイトへのリンク",
@@ -2603,10 +2611,12 @@
"Max successful requests": "最大成功リクエスト数",
"Max Successful Requests": "最大成功リクエスト数",
"Max Tokens": "最大トークン数",
"Maximum {{max}} groups selected": "最大 {{max}} グループを選択済み",
"Maximum 1000 characters. Supports Markdown and HTML.": "最大1000文字。MarkdownとHTMLをサポートしています。",
"Maximum 200 characters": "最大200文字",
"Maximum 500 characters. Supports Markdown and HTML.": "最大500文字。MarkdownとHTMLをサポートしています。",
"Maximum check-in quota": "最大チェックインクォータ",
"Maximum custom groups per token": "トークンごとのカスタムグループ上限",
"Maximum input window": "最大入力ウィンドウ",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "各ユーザーが作成できる最大トークン数。デフォルトは 1000。大きすぎる値はパフォーマンスに影響を与える可能性があります。",
"Maximum number of tokens in the response": "レスポンスの最大トークン数",
@@ -2757,6 +2767,8 @@
"More...": "その他...",
"Most-used models in the selected period and category": "選択した期間とカテゴリで最も使われているモデル",
"Move": "移動",
"Move {{group}} down": "{{group}} を下に移動",
"Move {{group}} up": "{{group}} を上に移動",
"Move a request header": "リクエストヘッダーを移動",
"Move affiliate rewards to your main balance": "アフィリエイト報酬をメイン残高に移動する",
"Move fallback to end": "フォールバックを最後へ",
@@ -2858,6 +2870,7 @@
"No app usage data available for this model.": "このモデルのアプリ利用データはまだありません。",
"No apps match the selected filters": "条件に一致するアプリはありません",
"No Auth": "認証なし",
"No available groups in the global Auto order.": "グローバル Auto 順序に利用可能なグループがありません。",
"No available models": "利用可能なモデルがありません",
"No available Web chat links": "利用可能なWebチャットリンクがありません",
"No backup": "バックアップなし",
@@ -2882,6 +2895,7 @@
"No console output": "コンソール出力なし",
"No containers": "コンテナがありません",
"No content to copy": "コピーする内容がありません",
"No custom groups. Saving will inherit the complete global Auto order.": "カスタムグループはありません。保存すると、グローバル Auto の全順序を継承します。",
"No custom OAuth providers configured yet.": "カスタムOAuthプロバイダーはまだ設定されていません。",
"No data": "データがありません",
"No Data": "データなし",
@@ -3006,6 +3020,7 @@
"No users": "ユーザーなし",
"No users available. Try adjusting your search or filters.": "利用可能なユーザーがいません。検索またはフィルターを調整してみてください。",
"No Users Found": "ユーザーが見つかりません",
"No valid custom Auto groups remain. Add a group or restore global Auto.": "有効なカスタム Auto グループがありません。グループを追加するか、グローバル Auto に戻してください。",
"No vendor data available": "ベンダーデータがありません",
"No X Found": "X が見つかりません",
"Node": "ノード",
@@ -3081,6 +3096,7 @@
"OIDC configuration fetched successfully": "OIDC 設定が正常に取得されました",
"OIDC discovery can fill the endpoint fields automatically when the provider supports it.": "プロバイダーが対応している場合、OIDC Discovery でエンドポイント項目を自動入力できます。",
"OIDC discovery URL. Click \"Auto-discover\" to fetch endpoints automatically.": "OIDCディスカバリーURL。「自動検出」をクリックすると、エンドポイントを自動的に取得します。",
"OIDC Display Name": "OIDC 表示名",
"OIDC endpoints discovered successfully": "OIDCエンドポイントの検出に成功しました",
"Old Format Template": "旧形式テンプレート",
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "旧形式: 直接上書き。新形式: 条件判定とカスタムJSON操作をサポートします。",
@@ -3725,6 +3741,7 @@
"Remaining:": "残り:",
"Remark": "備考",
"Remove": "削除",
"Remove {{group}}": "{{group}} を削除",
"Remove {{value}}": "{{value}} を削除",
"Remove ${{amount}}": "${{amount}}を削除",
"Remove all log entries created before the selected timestamp.": "選択したタイムスタンプより前に作成されたすべてのログエントリを削除します。",
@@ -3860,6 +3877,7 @@
"Response time: {{duration}}": "応答時間: {{duration}}",
"Responses API Version": "応答APIバージョン",
"Restore defaults": "既定に戻す",
"Restore global Auto": "グローバル Auto に戻す",
"Restrict user model request frequency (may impact high concurrency performance)": "ユーザーモデルのリクエスト頻度を制限する(高並行性パフォーマンスに影響を与える可能性があります)",
"Result": "結果",
"Retain last N days": "最新N日間を保持",
@@ -4051,8 +4069,10 @@
"Select All Visible": "表示中のすべてを選択",
"Select an operation mode and enter the amount": "操作モードを選択し、金額を入力してください",
"Select announcement type": "アナウンスメントタイプを選択",
"Select at least one Auto group or restore global Auto.": "Auto グループを1つ以上選択するか、グローバル Auto に戻してください。",
"Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。",
"Select at least one target model": "少なくとも1つの対象モデルを選択してください",
"Select at most {{max}} Auto groups": "Auto グループは最大 {{max}} 個まで選択できます",
"Select body font": "本文フォントを選択",
"Select border radius": "角丸を選択",
"Select channel type": "チャネルタイプを選択",
@@ -4994,6 +5014,7 @@
"Users of vip, when billed as premium, pay ratio": "vip グループのユーザーが premium として課金されるときの倍率は",
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "ユーザーにはユーザー選択可のグループだけが表示されます。選択不可グループも管理者は割り当てできます。",
"uses": "使用回数",
"Using the complete global Auto order ({{count}} groups)": "グローバル Auto の全順序を使用中({{count}} グループ)",
"Validity": "有効期間",
"Validity Period": "有効期間",
"Value": "値",
+24 -3
View File
@@ -31,6 +31,7 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Модели {{category}}",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} завершено",
"{{count}} / {{max}} groups selected": "Выбрано групп: {{count}} из {{max}}",
"{{count}} announcements will be removed from the list.": "{{count}} объявлений будут удалены из списка.",
"{{count}} API shortcuts will be removed from the list.": "{{count}} ярлыков API будут удалены из списка.",
"{{count}} channel(s) deleted": "Удалено {{count}} каналов",
@@ -169,6 +170,7 @@
"Add API": "Добавить API",
"Add API Shortcut": "Добавить ярлык API",
"Add auto group": "Добавить автогруппу",
"Add Auto group": "Добавить группу Auto",
"Add chat preset": "Добавить предустановку чата",
"Add condition": "Добавить условие",
"Add Condition": "Добавить условие",
@@ -503,6 +505,8 @@
"Auto Disabled": "Автоматически отключено",
"Auto group behavior": "Поведение группы auto",
"Auto Group Chain": "Автоматическая цепочка групп",
"Auto group order": "Порядок групп Auto",
"Auto groups must not contain duplicates": "Группы Auto не должны повторяться",
"Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "Авто согласовывает HTTP/2 при наличии. HTTP/1.1 использует несколько keep-alive соединений при параллельных запросах.",
"Auto refresh": "Автообновление",
"Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера",
@@ -799,6 +803,7 @@
"Chinese": "Китайский",
"Choose a username": "Выберите имя пользователя",
"Choose an amount and payment method": "Выберите сумму и способ оплаты",
"Choose and order the groups this API key will try.": "Выберите и упорядочьте группы, которые будет использовать этот API-ключ.",
"Choose between default expanded, compact icon-only, or full layout mode": "Выберите между развернутым по умолчанию, компактным (только иконки) или полным режимом макета",
"Choose between inset, floating, or standard sidebar layout": "Выберите между встроенным, плавающим или стандартным макетом боковой панели",
"Choose between left-to-right or right-to-left site direction": "Выберите между направлением сайта слева направо или справа налево",
@@ -1266,6 +1271,7 @@
"Default time granularity": "Гранулярность времени по умолчанию",
"Default to auto groups": "По умолчанию использовать автогруппы",
"Default TTL (seconds)": "TTL по умолчанию (секунды)",
"Defaults to \"OIDC\" if left blank": "Если оставить пустым, будет отображаться «OIDC».",
"Defaults to the wallet page when empty": "Если пусто, по умолчанию открывается страница кошелька",
"Define API endpoints for this model (JSON format)": "Определить конечные точки API для этой модели (формат JSON)",
"Define endpoint mappings for each provider.": "Определите сопоставления конечных точек для каждого провайдера.",
@@ -1443,6 +1449,7 @@
"Downgrade to pre-purchase group": "Понизить до группы до покупки",
"Downgrade to this group after the subscription expires": "Понизить до этой группы после истечения подписки",
"Download": "Скачать",
"Drag {{group}} to reorder": "Перетащите {{group}}, чтобы изменить порядок",
"Draw": "Рисование",
"Drawing": "Рисование",
"Drawing logs": "Журналы рисования",
@@ -1465,6 +1472,7 @@
"e.g. 8 means 1 USD = 8 units": "напр. 8 означает 1 USD = 8 единиц",
"e.g. Basic Plan": "напр. Базовый план",
"e.g. Clean tool parameters to avoid upstream validation errors": "напр. Очистить параметры инструментов во избежание ошибок валидации",
"e.g. Company SSO": "например, корпоративный SSO",
"e.g. example.com": "напр. example.com",
"e.g. gpt-4o, gemini-2.5-flash": "например, gpt-4o, gemini-2.5-flash",
"e.g. llama3.1:8b": "например llama3.1:8b",
@@ -1580,9 +1588,6 @@
"Enable LinuxDO OAuth": "Включить LinuxDO OAuth",
"Enable model performance metrics": "Включить метрики производительности моделей",
"Enable OIDC": "Включить OIDC",
"OIDC Display Name": "Отображаемое имя OIDC",
"e.g. Company SSO": "например, корпоративный SSO",
"Defaults to \"OIDC\" if left blank": "Если оставить пустым, будет отображаться «OIDC».",
"Enable or disable this channel": "Включить или отключить этот канал",
"Enable or disable this model": "Включить или отключить эту модель",
"Enable Passkey": "Включить Passkey",
@@ -1633,6 +1638,7 @@
"Enter 6-digit code": "Введите 6-значный код",
"Enter a name": "Введите имя",
"Enter a new name": "Введите новое имя",
"Enter a positive integer": "Введите положительное целое число",
"Enter a positive or negative amount to adjust the quota": "Введите положительную или отрицательную сумму для корректировки квоты",
"Enter a react-icons component name. Invalid names show no icon.": "Введите имя компонента react-icons. Недопустимые имена не отображают значок.",
"Enter a valid email or leave blank": "Введите действительный email или оставьте пустым",
@@ -2312,6 +2318,7 @@
"Incomplete": "Не завершено",
"Increased user quota by {{quota}}": "Квота пользователя увеличена на {{quota}}",
"Index": "Индекс",
"Inherit global Auto order": "Наследовать глобальный порядок Auto",
"Initial quota given to new users": "Начальная квота, предоставляемая новым пользователям",
"Initial quota given to new users ({{formattedQuota}})": "Начальная квота, предоставляемая новым пользователям ({{formattedQuota}})",
"Initialization failed, please try again.": "Инициализация не удалась, попробуйте ещё раз.",
@@ -2489,6 +2496,7 @@
"Limit Reached": "Достигнут лимит",
"Limit which models can be used with this key": "Ограничить модели, которые могут быть использованы с этим ключом",
"Limited": "Ограничено",
"Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "Ограничивает только снимки Auto для отдельных токенов. Глобальное наследование Auto не ограничено.",
"Limits token selection to a probability mass": "Ограничивает выбор токенов суммарной вероятностью",
"LingYiWanWu": "LingYiWanWu",
"Link to your documentation site": "Ссылка на ваш сайт документации",
@@ -2603,10 +2611,12 @@
"Max successful requests": "Макс. успешных запросов",
"Max Successful Requests": "Макс. успешных запросов",
"Max Tokens": "Макс. токены",
"Maximum {{max}} groups selected": "Выбрано максимально допустимое число групп: {{max}}",
"Maximum 1000 characters. Supports Markdown and HTML.": "Максимум 1000 символов. Поддерживает Markdown и HTML.",
"Maximum 200 characters": "Максимум 200 символов",
"Maximum 500 characters. Supports Markdown and HTML.": "Максимум 500 символов. Поддерживает Markdown и HTML.",
"Maximum check-in quota": "Максимальная квота регистрации",
"Maximum custom groups per token": "Максимум пользовательских групп на токен",
"Maximum input window": "Максимальное окно ввода",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Максимальное количество токенов, которое может создать каждый пользователь. По умолчанию 1000. Слишком большое значение может повлиять на производительность.",
"Maximum number of tokens in the response": "Максимальное число токенов в ответе",
@@ -2757,6 +2767,8 @@
"More...": "Подробнее...",
"Most-used models in the selected period and category": "Самые используемые модели в выбранном периоде и категории",
"Move": "Переместить",
"Move {{group}} down": "Переместить {{group}} вниз",
"Move {{group}} up": "Переместить {{group}} вверх",
"Move a request header": "Переместить заголовок запроса",
"Move affiliate rewards to your main balance": "Перевести партнерские вознаграждения на основной баланс",
"Move fallback to end": "Переместить резерв в конец",
@@ -2858,6 +2870,7 @@
"No app usage data available for this model.": "Данные об использовании приложений для этой модели пока недоступны.",
"No apps match the selected filters": "Нет приложений, соответствующих фильтрам",
"No Auth": "Без auth",
"No available groups in the global Auto order.": "В глобальном порядке Auto нет доступных групп.",
"No available models": "Нет доступных моделей",
"No available Web chat links": "Нет доступных веб-ссылок для чата",
"No backup": "Нет резервной копии",
@@ -2882,6 +2895,7 @@
"No console output": "Нет вывода консоли",
"No containers": "Нет контейнеров",
"No content to copy": "Нет содержимого для копирования",
"No custom groups. Saving will inherit the complete global Auto order.": "Пользовательские группы не заданы. После сохранения будет унаследован полный глобальный порядок Auto.",
"No custom OAuth providers configured yet.": "Пользовательские поставщики OAuth еще не настроены.",
"No data": "Нет данных",
"No Data": "Нет данных",
@@ -3006,6 +3020,7 @@
"No users": "Нет пользователей",
"No users available. Try adjusting your search or filters.": "Нет доступных пользователей. Попробуйте изменить параметры поиска или фильтры.",
"No Users Found": "Пользователи не найдены",
"No valid custom Auto groups remain. Add a group or restore global Auto.": "Доступных пользовательских групп Auto не осталось. Добавьте группу или восстановите глобальный порядок Auto.",
"No vendor data available": "Данных по поставщикам нет",
"No X Found": "X не найдено",
"Node": "Узел",
@@ -3081,6 +3096,7 @@
"OIDC configuration fetched successfully": "Конфигурация OIDC успешно получена",
"OIDC discovery can fill the endpoint fields automatically when the provider supports it.": "OIDC Discovery может автоматически заполнить поля конечных точек, если провайдер это поддерживает.",
"OIDC discovery URL. Click \"Auto-discover\" to fetch endpoints automatically.": "URL обнаружения OIDC. Нажмите \"Автообнаружение\" для автоматического получения конечных точек.",
"OIDC Display Name": "Отображаемое имя OIDC",
"OIDC endpoints discovered successfully": "Конечные точки OIDC успешно обнаружены",
"Old Format Template": "Шаблон старого формата",
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "Старый формат: прямое переопределение. Новый формат: поддерживает условные суждения и пользовательские операции JSON.",
@@ -3725,6 +3741,7 @@
"Remaining:": "Осталось:",
"Remark": "Примечания",
"Remove": "Удалить",
"Remove {{group}}": "Удалить {{group}}",
"Remove {{value}}": "Удалить {{value}}",
"Remove ${{amount}}": "Удалить ${{amount}}",
"Remove all log entries created before the selected timestamp.": "Удалить все записи журнала, созданные до выбранной отметки времени.",
@@ -3860,6 +3877,7 @@
"Response time: {{duration}}": "Время ответа: {{duration}}",
"Responses API Version": "Версия API ответов",
"Restore defaults": "Сбросить к значениям по умолчанию",
"Restore global Auto": "Восстановить глобальный Auto",
"Restrict user model request frequency (may impact high concurrency performance)": "Ограничить частоту запросов пользовательских моделей (может повлиять на производительность при высокой конкуренции)",
"Result": "Результат",
"Retain last N days": "Хранить последние N дней",
@@ -4051,8 +4069,10 @@
"Select All Visible": "Выбрать все видимые",
"Select an operation mode and enter the amount": "Выберите режим операции и введите сумму",
"Select announcement type": "Выбрать тип объявления",
"Select at least one Auto group or restore global Auto.": "Выберите хотя бы одну группу Auto или восстановите глобальный порядок Auto.",
"Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.",
"Select at least one target model": "Выберите хотя бы одну целевую модель",
"Select at most {{max}} Auto groups": "Выберите не более {{max}} групп Auto",
"Select body font": "Выберите шрифт текста",
"Select border radius": "Выберите радиус скругления",
"Select channel type": "Выбрать тип канала",
@@ -4994,6 +5014,7 @@
"Users of vip, when billed as premium, pay ratio": "Пользователи vip при тарификации по premium платят коэффициент",
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Пользователи видят только группы, отмеченные как доступные для выбора. Недоступные для выбора группы всё равно могут назначаться администраторами.",
"uses": "использует",
"Using the complete global Auto order ({{count}} groups)": "Используется полный глобальный порядок Auto (групп: {{count}})",
"Validity": "Срок действия",
"Validity Period": "Срок действия",
"Value": "Значение",
+24 -3
View File
@@ -31,6 +31,7 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Mô hình {{category}}",
"{{completed}}/{{total}} completed": "Đã hoàn tất {{completed}}/{{total}}",
"{{count}} / {{max}} groups selected": "Đã chọn {{count}} / {{max}} nhóm",
"{{count}} announcements will be removed from the list.": "{{count}} thông báo sẽ bị xóa khỏi danh sách.",
"{{count}} API shortcuts will be removed from the list.": "{{count}} lối tắt API sẽ bị xóa khỏi danh sách.",
"{{count}} channel(s) deleted": "Đã xóa {{count}} kênh",
@@ -169,6 +170,7 @@
"Add API": "Thêm API",
"Add API Shortcut": "Thêm lối tắt API",
"Add auto group": "Thêm nhóm tự động",
"Add Auto group": "Thêm nhóm Auto",
"Add chat preset": "Thêm mẫu trò chuyện",
"Add condition": "Thêm điều kiện",
"Add Condition": "Thêm điều kiện",
@@ -503,6 +505,8 @@
"Auto Disabled": "Vô hiệu hóa tự động",
"Auto group behavior": "Cách hoạt động của nhóm auto",
"Auto Group Chain": "Chuỗi nhóm tự động",
"Auto group order": "Thứ tự nhóm Auto",
"Auto groups must not contain duplicates": "Các nhóm Auto không được trùng lặp",
"Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "Tự động đàm phán HTTP/2 khi khả dụng. HTTP/1.1 buộc dùng nhiều kết nối keep-alive khi có đồng thời.",
"Auto refresh": "Tự động làm mới",
"Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn",
@@ -799,6 +803,7 @@
"Chinese": "Tiếng Trung",
"Choose a username": "Chọn tên người dùng",
"Choose an amount and payment method": "Chọn số tiền và phương thức thanh toán",
"Choose and order the groups this API key will try.": "Chọn và sắp xếp các nhóm mà khóa API này sẽ thử.",
"Choose between default expanded, compact icon-only, or full layout mode": "Chọn giữa chế độ mở rộng mặc định, chế độ chỉ biểu tượng thu gọn, hoặc chế độ bố cục đầy đủ",
"Choose between inset, floating, or standard sidebar layout": "Chọn giữa bố cục thanh bên chìm, nổi hoặc tiêu chuẩn",
"Choose between left-to-right or right-to-left site direction": "Chọn giữa hướng trang từ trái sang phải hoặc từ phải sang trái",
@@ -1266,6 +1271,7 @@
"Default time granularity": "Độ chi tiết thời gian mặc định",
"Default to auto groups": "Mặc định là nhóm tự động",
"Default TTL (seconds)": "TTL mặc định (giây)",
"Defaults to \"OIDC\" if left blank": "Mặc định là \"OIDC\" nếu để trống",
"Defaults to the wallet page when empty": "Để trống sẽ dùng trang ví mặc định",
"Define API endpoints for this model (JSON format)": "Định nghĩa các điểm cuối API cho mô hình này (định dạng JSON)",
"Define endpoint mappings for each provider.": "Định nghĩa ánh xạ điểm cuối cho mỗi nhà cung cấp.",
@@ -1443,6 +1449,7 @@
"Downgrade to pre-purchase group": "Hạ xuống nhóm trước khi mua",
"Downgrade to this group after the subscription expires": "Hạ xuống nhóm này sau khi đăng ký hết hạn",
"Download": "Tải xuống",
"Drag {{group}} to reorder": "Kéo {{group}} để sắp xếp lại",
"Draw": "Vẽ",
"Drawing": "Vẽ",
"Drawing logs": "Nhật ký vẽ",
@@ -1465,6 +1472,7 @@
"e.g. 8 means 1 USD = 8 units": "Ví dụ: 8 có nghĩa là 1 USD = 8 đơn vị",
"e.g. Basic Plan": "ví dụ: Gói cơ bản",
"e.g. Clean tool parameters to avoid upstream validation errors": "ví dụ: Dọn dẹp tham số công cụ để tránh lỗi xác thực upstream",
"e.g. Company SSO": "ví dụ: SSO công ty",
"e.g. example.com": "ví dụ example.com",
"e.g. gpt-4o, gemini-2.5-flash": "ví dụ gpt-4o, gemini-2.5-flash",
"e.g. llama3.1:8b": "ví dụ: llama3.1:8b",
@@ -1580,9 +1588,6 @@
"Enable LinuxDO OAuth": "Bật LinuxDO OAuth",
"Enable model performance metrics": "Bật chỉ số hiệu năng mô hình",
"Enable OIDC": "Bật OIDC",
"OIDC Display Name": "Tên hiển thị OIDC",
"e.g. Company SSO": "ví dụ: SSO công ty",
"Defaults to \"OIDC\" if left blank": "Mặc định là \"OIDC\" nếu để trống",
"Enable or disable this channel": "Bật hoặc tắt kênh này",
"Enable or disable this model": "Bật hoặc tắt mô hình này",
"Enable Passkey": "Bật khóa truy cập",
@@ -1633,6 +1638,7 @@
"Enter 6-digit code": "Nhập mã 6 chữ số",
"Enter a name": "Nhập tên",
"Enter a new name": "Nhập tên mới",
"Enter a positive integer": "Nhập một số nguyên dương",
"Enter a positive or negative amount to adjust the quota": "Nhập một giá trị dương hoặc âm để điều chỉnh hạn ngạch",
"Enter a react-icons component name. Invalid names show no icon.": "Nhập tên component react-icons. Tên không hợp lệ sẽ không hiển thị biểu tượng.",
"Enter a valid email or leave blank": "Nhập email hợp lệ hoặc để trống",
@@ -2312,6 +2318,7 @@
"Incomplete": "Chưa hoàn tất",
"Increased user quota by {{quota}}": "Đã tăng hạn mức người dùng thêm {{quota}}",
"Index": "Chỉ mục",
"Inherit global Auto order": "Kế thừa thứ tự Auto toàn cục",
"Initial quota given to new users": "Hạn mức ban đầu cấp cho người dùng mới",
"Initial quota given to new users ({{formattedQuota}})": "Hạn mức ban đầu cấp cho người dùng mới ({{formattedQuota}})",
"Initialization failed, please try again.": "Khởi tạo thất bại, vui lòng thử lại.",
@@ -2489,6 +2496,7 @@
"Limit Reached": "Đã đạt giới hạn",
"Limit which models can be used with this key": "Giới hạn các mô hình có thể được sử dụng với khóa này",
"Limited": "Giới hạn",
"Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "Chỉ giới hạn cấu hình Auto riêng của token. Việc kế thừa Auto toàn cục không bị giới hạn.",
"Limits token selection to a probability mass": "Giới hạn lựa chọn token theo khối xác suất",
"LingYiWanWu": "LingYiWanWu",
"Link to your documentation site": "Liên kết đến trang web tài liệu của bạn",
@@ -2603,10 +2611,12 @@
"Max successful requests": "Số yêu cầu thành công tối đa",
"Max Successful Requests": "Yêu cầu thành công tối đa",
"Max Tokens": "Token tối đa",
"Maximum {{max}} groups selected": "Đã chọn tối đa {{max}} nhóm",
"Maximum 1000 characters. Supports Markdown and HTML.": "Tối đa 1000 ký tự. Hỗ trợ Markdown và HTML.",
"Maximum 200 characters": "Tối đa 200 ký tự",
"Maximum 500 characters. Supports Markdown and HTML.": "Tối đa 500 ký tự. Hỗ trợ Markdown và HTML.",
"Maximum check-in quota": "Hạn ngạch điểm danh tối đa",
"Maximum custom groups per token": "Số nhóm tùy chỉnh tối đa cho mỗi token",
"Maximum input window": "Cửa sổ nhập tối đa",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Số lượng token tối đa mỗi người dùng có thể tạo. Mặc định là 1000. Đặt quá lớn có thể ảnh hưởng đến hiệu suất.",
"Maximum number of tokens in the response": "Số token tối đa trong phản hồi",
@@ -2757,6 +2767,8 @@
"More...": "Thêm...",
"Most-used models in the selected period and category": "Mô hình được dùng nhiều nhất trong khoảng thời gian và danh mục đã chọn",
"Move": "Di chuyển",
"Move {{group}} down": "Di chuyển {{group}} xuống",
"Move {{group}} up": "Di chuyển {{group}} lên",
"Move a request header": "Di chuyển header yêu cầu",
"Move affiliate rewards to your main balance": "Chuyển phần thưởng liên kết vào số dư chính của bạn",
"Move fallback to end": "Đưa dự phòng xuống cuối",
@@ -2858,6 +2870,7 @@
"No app usage data available for this model.": "Chưa có dữ liệu sử dụng ứng dụng cho mô hình này.",
"No apps match the selected filters": "Không có ứng dụng phù hợp bộ lọc",
"No Auth": "Không xác thực",
"No available groups in the global Auto order.": "Không có nhóm khả dụng trong thứ tự Auto toàn cục.",
"No available models": "Không có mô hình khả dụng",
"No available Web chat links": "Không có liên kết Web chat khả dụng",
"No backup": "Chưa sao lưu",
@@ -2882,6 +2895,7 @@
"No console output": "Không có đầu ra console",
"No containers": "Không có container",
"No content to copy": "Không có nội dung để sao chép",
"No custom groups. Saving will inherit the complete global Auto order.": "Chưa có nhóm tùy chỉnh. Sau khi lưu, thứ tự Auto toàn cục đầy đủ sẽ được kế thừa.",
"No custom OAuth providers configured yet.": "Chưa có nhà cung cấp OAuth tùy chỉnh nào được cấu hình.",
"No data": "Không có dữ liệu",
"No Data": "Không có dữ liệu",
@@ -3006,6 +3020,7 @@
"No users": "Không có người dùng",
"No users available. Try adjusting your search or filters.": "Không có người dùng nào. Hãy thử điều chỉnh tìm kiếm hoặc bộ lọc của bạn.",
"No Users Found": "Không tìm thấy người dùng nào",
"No valid custom Auto groups remain. Add a group or restore global Auto.": "Không còn nhóm Auto tùy chỉnh hợp lệ. Hãy thêm nhóm hoặc khôi phục Auto toàn cục.",
"No vendor data available": "Không có dữ liệu nhà cung cấp",
"No X Found": "Không tìm thấy X",
"Node": "Nút",
@@ -3081,6 +3096,7 @@
"OIDC configuration fetched successfully": "Cấu hình OIDC đã được lấy thành công",
"OIDC discovery can fill the endpoint fields automatically when the provider supports it.": "OIDC Discovery có thể tự động điền các trường endpoint khi nhà cung cấp hỗ trợ.",
"OIDC discovery URL. Click \"Auto-discover\" to fetch endpoints automatically.": "URL khám phá OIDC. Nhấp \"Tự động khám phá\" để tự động lấy các endpoint.",
"OIDC Display Name": "Tên hiển thị OIDC",
"OIDC endpoints discovered successfully": "Đã khám phá thành công các endpoint OIDC",
"Old Format Template": "Mẫu Định dạng Cũ",
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "Định dạng cũ: Ghi đè trực tiếp. Định dạng mới: Hỗ trợ phán đoán có điều kiện và các thao tác JSON tùy chỉnh.",
@@ -3725,6 +3741,7 @@
"Remaining:": "Còn lại:",
"Remark": "Nhận xét",
"Remove": "Xóa",
"Remove {{group}}": "Xóa {{group}}",
"Remove {{value}}": "Xóa {{value}}",
"Remove ${{amount}}": "Xóa ${{amount}}",
"Remove all log entries created before the selected timestamp.": "Xóa tất cả các mục nhật ký được tạo trước mốc thời gian đã chọn.",
@@ -3860,6 +3877,7 @@
"Response time: {{duration}}": "Thời gian phản hồi: {{duration}}",
"Responses API Version": "Phiên bản API Phản hồi",
"Restore defaults": "Khôi phục mặc định",
"Restore global Auto": "Khôi phục Auto toàn cục",
"Restrict user model request frequency (may impact high concurrency performance)": "Hạn chế tần suất yêu cầu mô hình người dùng (có thể ảnh hưởng đến hiệu suất khi có độ đồng thời cao)",
"Result": "Kết quả",
"Retain last N days": "Giữ lại N ngày gần nhất",
@@ -4051,8 +4069,10 @@
"Select All Visible": "Chọn tất cả hiển thị",
"Select an operation mode and enter the amount": "Chọn chế độ thao tác và nhập số tiền",
"Select announcement type": "Select notification type",
"Select at least one Auto group or restore global Auto.": "Chọn ít nhất một nhóm Auto hoặc khôi phục Auto toàn cục.",
"Select at least one field to overwrite.": "Chọn ít nhất một trường để ghi đè.",
"Select at least one target model": "Chọn ít nhất một mô hình đích",
"Select at most {{max}} Auto groups": "Chọn tối đa {{max}} nhóm Auto",
"Select body font": "Chọn phông chữ nội dung",
"Select border radius": "Chọn độ bo góc",
"Select channel type": "Chọn loại kênh",
@@ -4994,6 +5014,7 @@
"Users of vip, when billed as premium, pay ratio": "Người dùng của vip, khi tính phí theo premium, trả hệ số",
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Người dùng chỉ thấy các nhóm được đánh dấu là có thể chọn. Nhóm không thể chọn vẫn có thể do quản trị viên gán.",
"uses": "sử dụng",
"Using the complete global Auto order ({{count}} groups)": "Đang dùng thứ tự Auto toàn cục đầy đủ ({{count}} nhóm)",
"Validity": "Hiệu lực",
"Validity Period": "Thời hạn hiệu lực",
"Value": "Giá trị",
+24 -3
View File
@@ -31,6 +31,7 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "{{category}} 模型",
"{{completed}}/{{total}} completed": "已完成 {{completed}}/{{total}}",
"{{count}} / {{max}} groups selected": "已選擇 {{count}} / {{max}} 個分組",
"{{count}} announcements will be removed from the list.": "將從列表中移除 {{count}} 條公告。",
"{{count}} API shortcuts will be removed from the list.": "將從列表中移除 {{count}} 個 API 快捷方式。",
"{{count}} channel(s) deleted": "已刪除 {{count}} 個渠道",
@@ -169,6 +170,7 @@
"Add API": "新增 API",
"Add API Shortcut": "新增 API 快捷方式",
"Add auto group": "新增自動分組",
"Add Auto group": "新增 Auto 分組",
"Add chat preset": "新增聊天預設",
"Add condition": "新增條件",
"Add Condition": "新增條件",
@@ -503,6 +505,8 @@
"Auto Disabled": "自動停用",
"Auto group behavior": "自動分組行為",
"Auto Group Chain": "自動分組鏈",
"Auto group order": "Auto 分組順序",
"Auto groups must not contain duplicates": "Auto 分組不得重複",
"Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "自動在可用時協商 HTTP/2。HTTP/1.1 會在並發時使用多條保持連線的連線。",
"Auto refresh": "自動重新整理",
"Auto Sync Upstream Models": "自動同步上游模型",
@@ -799,6 +803,7 @@
"Chinese": "中文",
"Choose a username": "選擇一個用戶名",
"Choose an amount and payment method": "選擇金額和支付方式",
"Choose and order the groups this API key will try.": "選擇此 API 金鑰要依序嘗試的分組並排序。",
"Choose between default expanded, compact icon-only, or full layout mode": "選擇預設展開、緊湊圖標模式或完整佈局模式",
"Choose between inset, floating, or standard sidebar layout": "選擇嵌入式、浮動式或標準側邊欄佈局",
"Choose between left-to-right or right-to-left site direction": "選擇從左到右或從右到左的站點方向",
@@ -1266,6 +1271,7 @@
"Default time granularity": "預設時間粒度",
"Default to auto groups": "預設使用自動分組",
"Default TTL (seconds)": "預設 TTL(秒)",
"Defaults to \"OIDC\" if left blank": "留空則預設顯示為 \"OIDC\"",
"Defaults to the wallet page when empty": "為空時預設使用錢包頁面",
"Define API endpoints for this model (JSON format)": "為此模型定義 API 端點(JSON 格式)",
"Define endpoint mappings for each provider.": "為每個供應商定義端點映射。",
@@ -1443,6 +1449,7 @@
"Downgrade to pre-purchase group": "降級到購買前分組",
"Downgrade to this group after the subscription expires": "訂閱過期後降級到該分組",
"Download": "下載",
"Drag {{group}} to reorder": "拖曳 {{group}} 以重新排序",
"Draw": "繪圖",
"Drawing": "繪圖",
"Drawing logs": "繪製日誌",
@@ -1465,6 +1472,7 @@
"e.g. 8 means 1 USD = 8 units": "例如,8 表示 1 美元 = 8 單位",
"e.g. Basic Plan": "例如:基礎套餐",
"e.g. Clean tool parameters to avoid upstream validation errors": "例如:清理工具參數,避免上游校驗錯誤",
"e.g. Company SSO": "例如:公司 SSO",
"e.g. example.com": "例如,example.com",
"e.g. gpt-4o, gemini-2.5-flash": "例如 gpt-4o, gemini-2.5-flash",
"e.g. llama3.1:8b": "例如 llama3.1:8b",
@@ -1580,9 +1588,6 @@
"Enable LinuxDO OAuth": "啟用 LinuxDO OAuth",
"Enable model performance metrics": "啟用模型效能指標",
"Enable OIDC": "啟用 OIDC",
"OIDC Display Name": "OIDC 顯示名稱",
"e.g. Company SSO": "例如:公司 SSO",
"Defaults to \"OIDC\" if left blank": "留空則預設顯示為 \"OIDC\"",
"Enable or disable this channel": "啟用或停用此渠道",
"Enable or disable this model": "啟用或停用此模型",
"Enable Passkey": "啟用 Passkey",
@@ -1633,6 +1638,7 @@
"Enter 6-digit code": "輸入 6 位數字代碼",
"Enter a name": "輸入名稱",
"Enter a new name": "輸入新名稱",
"Enter a positive integer": "請輸入正整數",
"Enter a positive or negative amount to adjust the quota": "輸入正數或負數以調整配額",
"Enter a react-icons component name. Invalid names show no icon.": "輸入 react-icons 組件名。無法解析的名稱不會顯示圖標。",
"Enter a valid email or leave blank": "請輸入有效的電郵地址或留空",
@@ -2312,6 +2318,7 @@
"Incomplete": "未完成",
"Increased user quota by {{quota}}": "增加用戶額度 {{quota}}",
"Index": "索引",
"Inherit global Auto order": "繼承全域 Auto 順序",
"Initial quota given to new users": "授予新用戶的初始配額",
"Initial quota given to new users ({{formattedQuota}})": "授予新用戶的初始配額({{formattedQuota}}",
"Initialization failed, please try again.": "初始化失敗,請重試。",
@@ -2489,6 +2496,7 @@
"Limit Reached": "已達上限",
"Limit which models can be used with this key": "限制此金鑰可使用的模型",
"Limited": "受限",
"Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "僅限制令牌專屬的 Auto 快照;繼承全域 Auto 時不受限制。",
"Limits token selection to a probability mass": "依機率質量限制詞元選擇範圍",
"LingYiWanWu": "LingYiWanWu",
"Link to your documentation site": "您的文件站點連結",
@@ -2603,10 +2611,12 @@
"Max successful requests": "最大成功請求數",
"Max Successful Requests": "最大成功請求數",
"Max Tokens": "最大 Tokens",
"Maximum {{max}} groups selected": "已達到 {{max}} 個分組上限",
"Maximum 1000 characters. Supports Markdown and HTML.": "最多 1000 個字元。支援 Markdown 和 HTML。",
"Maximum 200 characters": "最多 200 個字元",
"Maximum 500 characters. Supports Markdown and HTML.": "最多 500 個字元。支援 Markdown 和 HTML。",
"Maximum check-in quota": "簽到最大額度",
"Maximum custom groups per token": "每個令牌的最大自訂分組數",
"Maximum input window": "最大輸入窗口",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每個用戶可建立的最大令牌數量。預設 1000。設定過大可能會影響效能。",
"Maximum number of tokens in the response": "回應中最大 token 數",
@@ -2757,6 +2767,8 @@
"More...": "更多...",
"Most-used models in the selected period and category": "所選時間範圍與分類下使用率最高的模型",
"Move": "移動",
"Move {{group}} down": "將 {{group}} 下移",
"Move {{group}} up": "將 {{group}} 上移",
"Move a request header": "移動請求頭",
"Move affiliate rewards to your main balance": "將推廣獎勵轉移到您的主餘額",
"Move fallback to end": "兜底移到最後",
@@ -2858,6 +2870,7 @@
"No app usage data available for this model.": "該模型暫無套用使用數據。",
"No apps match the selected filters": "沒有匹配篩選條件的套用",
"No Auth": "無認證",
"No available groups in the global Auto order.": "全域 Auto 順序中目前沒有可用分組。",
"No available models": "沒有可用模型",
"No available Web chat links": "沒有可用的 Web 聊天連結",
"No backup": "無備份",
@@ -2882,6 +2895,7 @@
"No console output": "無控制台輸出",
"No containers": "無容器",
"No content to copy": "沒有可複製的內容",
"No custom groups. Saving will inherit the complete global Auto order.": "未自訂分組。儲存後將繼承完整的全域 Auto 順序。",
"No custom OAuth providers configured yet.": "尚未設定自訂 OAuth 供應商。",
"No data": "暫無數據",
"No Data": "無數據",
@@ -3006,6 +3020,7 @@
"No users": "無用戶",
"No users available. Try adjusting your search or filters.": "沒有可用的用戶。請嘗試調整您的搜尋或篩選條件。",
"No Users Found": "未找到用戶",
"No valid custom Auto groups remain. Add a group or restore global Auto.": "沒有可用的自訂 Auto 分組。請新增分組或恢復全域 Auto。",
"No vendor data available": "暫無廠商數據",
"No X Found": "未找到 X",
"Node": "節點",
@@ -3081,6 +3096,7 @@
"OIDC configuration fetched successfully": "OIDC 設定獲取成功",
"OIDC discovery can fill the endpoint fields automatically when the provider supports it.": "提供商支援時,OIDC Discovery 可以自動填入端點欄位。",
"OIDC discovery URL. Click \"Auto-discover\" to fetch endpoints automatically.": "OIDC 發現 URL。點擊「自動發現」以自動獲取端點。",
"OIDC Display Name": "OIDC 顯示名稱",
"OIDC endpoints discovered successfully": "OIDC 端點發現成功",
"Old Format Template": "舊格式模板",
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "舊格式:直接覆蓋。新格式:支援條件判斷和自訂 JSON 操作。",
@@ -3725,6 +3741,7 @@
"Remaining:": "剩餘:",
"Remark": "備註",
"Remove": "移除",
"Remove {{group}}": "移除 {{group}}",
"Remove {{value}}": "移除 {{value}}",
"Remove ${{amount}}": "移除 ${{amount}}",
"Remove all log entries created before the selected timestamp.": "移除所選時間戳之前建立的所有日誌條目。",
@@ -3860,6 +3877,7 @@
"Response time: {{duration}}": "回應時間:{{duration}}",
"Responses API Version": "回應 API 版本",
"Restore defaults": "恢復預設",
"Restore global Auto": "恢復全域 Auto",
"Restrict user model request frequency (may impact high concurrency performance)": "限制用戶模型請求頻率(可能會影響高並發效能)",
"Result": "結果",
"Retain last N days": "保留最近N天",
@@ -4051,8 +4069,10 @@
"Select All Visible": "全選目前",
"Select an operation mode and enter the amount": "選擇操作模式並輸入金額",
"Select announcement type": "選擇公告類型",
"Select at least one Auto group or restore global Auto.": "請至少選擇一個 Auto 分組,或恢復全域 Auto。",
"Select at least one field to overwrite.": "請選擇至少一個要覆蓋的欄位。",
"Select at least one target model": "請至少選擇一個目標模型",
"Select at most {{max}} Auto groups": "最多選擇 {{max}} 個 Auto 分組",
"Select body font": "選擇正文字體",
"Select border radius": "選擇圓角大小",
"Select channel type": "選擇渠道類型",
@@ -4994,6 +5014,7 @@
"Users of vip, when billed as premium, pay ratio": "vip 分組的用戶,按 premium 收費時,倍率用",
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "用戶只能看到標記為用戶可選的分組。不可選分組仍可由管理員分配。",
"uses": "使用次數",
"Using the complete global Auto order ({{count}} groups)": "正在使用完整的全域 Auto 順序({{count}} 個分組)",
"Validity": "有效期",
"Validity Period": "有效期",
"Value": "值",
+24 -3
View File
@@ -31,6 +31,7 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "{{category}} 模型",
"{{completed}}/{{total}} completed": "已完成 {{completed}}/{{total}}",
"{{count}} / {{max}} groups selected": "已选择 {{count}} / {{max}} 个分组",
"{{count}} announcements will be removed from the list.": "将从列表中移除 {{count}} 条公告。",
"{{count}} API shortcuts will be removed from the list.": "将从列表中移除 {{count}} 个 API 快捷方式。",
"{{count}} channel(s) deleted": "已删除 {{count}} 个渠道",
@@ -169,6 +170,7 @@
"Add API": "添加 API",
"Add API Shortcut": "添加 API 快捷方式",
"Add auto group": "添加自动分组",
"Add Auto group": "添加 Auto 分组",
"Add chat preset": "添加聊天预设",
"Add condition": "新增条件",
"Add Condition": "添加条件",
@@ -503,6 +505,8 @@
"Auto Disabled": "自动禁用",
"Auto group behavior": "自动分组行为",
"Auto Group Chain": "自动分组链",
"Auto group order": "Auto 分组顺序",
"Auto groups must not contain duplicates": "Auto 分组不能重复",
"Auto negotiates HTTP/2 when available. HTTP/1.1 forces multiple keep-alive connections under concurrency.": "自动在可用时协商 HTTP/2。HTTP/1.1 会在并发时使用多条保持连接的连接。",
"Auto refresh": "自动刷新",
"Auto Sync Upstream Models": "自动同步上游模型",
@@ -799,6 +803,7 @@
"Chinese": "中文",
"Choose a username": "选择一个用户名",
"Choose an amount and payment method": "选择金额和支付方式",
"Choose and order the groups this API key will try.": "选择并排列此 API 密钥将依次尝试的分组。",
"Choose between default expanded, compact icon-only, or full layout mode": "选择默认展开、紧凑图标模式或完整布局模式",
"Choose between inset, floating, or standard sidebar layout": "选择嵌入式、浮动式或标准侧边栏布局",
"Choose between left-to-right or right-to-left site direction": "选择从左到右或从右到左的站点方向",
@@ -1266,6 +1271,7 @@
"Default time granularity": "默认时间粒度",
"Default to auto groups": "默认使用自动分组",
"Default TTL (seconds)": "默认 TTL(秒)",
"Defaults to \"OIDC\" if left blank": "留空则默认显示为 \"OIDC\"",
"Defaults to the wallet page when empty": "为空时默认使用钱包页面",
"Define API endpoints for this model (JSON format)": "为此模型定义 API 端点(JSON 格式)",
"Define endpoint mappings for each provider.": "为每个提供商定义端点映射。",
@@ -1443,6 +1449,7 @@
"Downgrade to pre-purchase group": "降级到购买前分组",
"Downgrade to this group after the subscription expires": "订阅过期后降级到该分组",
"Download": "下载",
"Drag {{group}} to reorder": "拖动 {{group}} 以重新排序",
"Draw": "绘图",
"Drawing": "绘图",
"Drawing logs": "绘制日志",
@@ -1465,6 +1472,7 @@
"e.g. 8 means 1 USD = 8 units": "例如,8 表示 1 美元 = 8 单位",
"e.g. Basic Plan": "例如:基础套餐",
"e.g. Clean tool parameters to avoid upstream validation errors": "例如:清理工具参数,避免上游校验错误",
"e.g. Company SSO": "例如:公司 SSO",
"e.g. example.com": "例如,example.com",
"e.g. gpt-4o, gemini-2.5-flash": "例如 gpt-4o, gemini-2.5-flash",
"e.g. llama3.1:8b": "例如 llama3.1:8b",
@@ -1580,9 +1588,6 @@
"Enable LinuxDO OAuth": "启用 LinuxDO OAuth",
"Enable model performance metrics": "启用模型性能指标",
"Enable OIDC": "启用 OIDC",
"OIDC Display Name": "OIDC 显示名称",
"e.g. Company SSO": "例如:公司 SSO",
"Defaults to \"OIDC\" if left blank": "留空则默认显示为 \"OIDC\"",
"Enable or disable this channel": "启用或禁用此渠道",
"Enable or disable this model": "启用或禁用此模型",
"Enable Passkey": "启用 Passkey",
@@ -1633,6 +1638,7 @@
"Enter 6-digit code": "输入 6 位数字代码",
"Enter a name": "输入名称",
"Enter a new name": "输入新名称",
"Enter a positive integer": "请输入正整数",
"Enter a positive or negative amount to adjust the quota": "输入正数或负数以调整配额",
"Enter a react-icons component name. Invalid names show no icon.": "输入 react-icons 组件名。无法解析的名称不会显示图标。",
"Enter a valid email or leave blank": "请输入有效的邮箱地址或留空",
@@ -2312,6 +2318,7 @@
"Incomplete": "未完成",
"Increased user quota by {{quota}}": "增加用户额度 {{quota}}",
"Index": "索引",
"Inherit global Auto order": "继承全局 Auto 顺序",
"Initial quota given to new users": "授予新用户的初始配额",
"Initial quota given to new users ({{formattedQuota}})": "授予新用户的初始配额({{formattedQuota}}",
"Initialization failed, please try again.": "初始化失败,请重试。",
@@ -2489,6 +2496,7 @@
"Limit Reached": "已达上限",
"Limit which models can be used with this key": "限制此密钥可使用的模型",
"Limited": "受限",
"Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "仅限制令牌专属的 Auto 快照;继承全局 Auto 时不受此限制。",
"Limits token selection to a probability mass": "按概率质量限制词元选择范围",
"LingYiWanWu": "LingYiWanWu",
"Link to your documentation site": "您的文档站点链接",
@@ -2603,10 +2611,12 @@
"Max successful requests": "最大成功请求数",
"Max Successful Requests": "最大成功请求数",
"Max Tokens": "最大 Tokens",
"Maximum {{max}} groups selected": "已达到 {{max}} 个分组上限",
"Maximum 1000 characters. Supports Markdown and HTML.": "最多 1000 个字符。支持 Markdown 和 HTML。",
"Maximum 200 characters": "最多 200 个字符",
"Maximum 500 characters. Supports Markdown and HTML.": "最多 500 个字符。支持 Markdown 和 HTML。",
"Maximum check-in quota": "签到最大额度",
"Maximum custom groups per token": "每个令牌的最大自定义分组数",
"Maximum input window": "最大输入窗口",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每个用户可创建的最大令牌数量。默认 1000。设置过大可能会影响性能。",
"Maximum number of tokens in the response": "响应中最大 token 数",
@@ -2757,6 +2767,8 @@
"More...": "更多...",
"Most-used models in the selected period and category": "所选时间范围与分类下使用率最高的模型",
"Move": "移动",
"Move {{group}} down": "将 {{group}} 下移",
"Move {{group}} up": "将 {{group}} 上移",
"Move a request header": "移动请求头",
"Move affiliate rewards to your main balance": "将推广奖励转移到您的主余额",
"Move fallback to end": "兜底移到最后",
@@ -2858,6 +2870,7 @@
"No app usage data available for this model.": "该模型暂无应用使用数据。",
"No apps match the selected filters": "没有匹配筛选条件的应用",
"No Auth": "无认证",
"No available groups in the global Auto order.": "全局 Auto 顺序中当前没有可用分组。",
"No available models": "没有可用模型",
"No available Web chat links": "没有可用的 Web 聊天链接",
"No backup": "无备份",
@@ -2882,6 +2895,7 @@
"No console output": "无控制台输出",
"No containers": "无容器",
"No content to copy": "没有可复制的内容",
"No custom groups. Saving will inherit the complete global Auto order.": "未自定义分组。保存后将继承完整的全局 Auto 顺序。",
"No custom OAuth providers configured yet.": "尚未配置自定义 OAuth 提供商。",
"No data": "暂无数据",
"No Data": "无数据",
@@ -3006,6 +3020,7 @@
"No users": "无用户",
"No users available. Try adjusting your search or filters.": "没有可用的用户。请尝试调整您的搜索或筛选条件。",
"No Users Found": "未找到用户",
"No valid custom Auto groups remain. Add a group or restore global Auto.": "没有可用的自定义 Auto 分组。请添加分组或恢复全局 Auto。",
"No vendor data available": "暂无厂商数据",
"No X Found": "未找到 X",
"Node": "节点",
@@ -3081,6 +3096,7 @@
"OIDC configuration fetched successfully": "OIDC 配置获取成功",
"OIDC discovery can fill the endpoint fields automatically when the provider supports it.": "当提供商支持时,OIDC Discovery 可以自动填充端点字段。",
"OIDC discovery URL. Click \"Auto-discover\" to fetch endpoints automatically.": "OIDC 发现 URL。点击\"自动发现\"以自动获取端点。",
"OIDC Display Name": "OIDC 显示名称",
"OIDC endpoints discovered successfully": "OIDC 端点发现成功",
"Old Format Template": "旧格式模板",
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "旧格式:直接覆盖。新格式:支持条件判断和自定义 JSON 操作。",
@@ -3725,6 +3741,7 @@
"Remaining:": "剩余:",
"Remark": "备注",
"Remove": "移除",
"Remove {{group}}": "移除 {{group}}",
"Remove {{value}}": "移除 {{value}}",
"Remove ${{amount}}": "移除 ${{amount}}",
"Remove all log entries created before the selected timestamp.": "移除所选时间戳之前创建的所有日志条目。",
@@ -3860,6 +3877,7 @@
"Response time: {{duration}}": "响应时间:{{duration}}",
"Responses API Version": "响应 API 版本",
"Restore defaults": "恢复默认",
"Restore global Auto": "恢复全局 Auto",
"Restrict user model request frequency (may impact high concurrency performance)": "限制用户模型请求频率(可能会影响高并发性能)",
"Result": "结果",
"Retain last N days": "保留最近N天",
@@ -4051,8 +4069,10 @@
"Select All Visible": "全选当前",
"Select an operation mode and enter the amount": "选择操作模式并输入金额",
"Select announcement type": "选择公告类型",
"Select at least one Auto group or restore global Auto.": "请至少选择一个 Auto 分组,或恢复全局 Auto。",
"Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。",
"Select at least one target model": "请至少选择一个目标模型",
"Select at most {{max}} Auto groups": "最多选择 {{max}} 个 Auto 分组",
"Select body font": "选择正文字体",
"Select border radius": "选择圆角大小",
"Select channel type": "选择渠道类型",
@@ -4994,6 +5014,7 @@
"Users of vip, when billed as premium, pay ratio": "vip 分组的用户,按 premium 计费时,倍率用",
"Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "用户只能看到标记为用户可选的分组。不可选分组仍可由管理员分配。",
"uses": "使用次数",
"Using the complete global Auto order ({{count}} groups)": "正在使用完整全局 Auto 顺序({{count}} 个分组)",
"Validity": "有效期",
"Validity Period": "有效期",
"Value": "值",
+46
View File
@@ -650,3 +650,49 @@ For commercial licensing, please contact support@quantumnous.com
animation: none !important;
}
}
/* ── Auto group flowing border ── */
@property --auto-group-flow-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes auto-group-flow-border-travel {
to {
--auto-group-flow-angle: 360deg;
}
}
/* Border-only effect: the conic gradient covers the whole layer, but the
* two-layer mask (content-box XOR full box) keeps only a `padding`-wide
* ring visible, so the highlight hugs the rounded perimeter. Animating
* the gradient's start angle makes the bright segment travel around all
* four edges and corners without touching the interior. */
.auto-group-flow-border {
padding: 1.5px;
border-radius: inherit;
background: conic-gradient(
from var(--auto-group-flow-angle),
transparent 0deg,
transparent 240deg,
color-mix(in oklch, var(--primary) 45%, transparent) 300deg,
var(--primary) 342deg,
transparent 360deg
);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: auto-group-flow-border-travel 3.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.auto-group-flow-border {
display: none;
}
}