Commit 0ab02020 by Calcium-Ion Committed by GitHub

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
parent bd585d78
...@@ -19,6 +19,7 @@ const ( ...@@ -19,6 +19,7 @@ const (
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit" ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
ContextKeyTokenAutoGroups ContextKey = "token_auto_groups"
/* channel related keys */ /* channel related keys */
ContextKeyChannelId ContextKey = "channel_id" ContextKeyChannelId ContextKey = "channel_id"
......
...@@ -20,6 +20,7 @@ import ( ...@@ -20,6 +20,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/samber/lo" "github.com/samber/lo"
) )
...@@ -190,7 +191,7 @@ func getModelListGroups(c *gin.Context) (modelListGroups, error) { ...@@ -190,7 +191,7 @@ func getModelListGroups(c *gin.Context) (modelListGroups, error) {
return modelListGroups{ return modelListGroups{
userGroup: userGroup, userGroup: userGroup,
tokenGroup: tokenGroup, tokenGroup: tokenGroup,
ownerGroups: service.GetUserAutoGroup(userGroup), ownerGroups: service.GetRequestAutoGroups(c, userGroup),
}, nil }, nil
} }
...@@ -228,32 +229,28 @@ func ListModels(c *gin.Context, modelType int) { ...@@ -228,32 +229,28 @@ func ListModels(c *gin.Context, modelType int) {
} }
ownerGroups := groups.ownerGroups ownerGroups := groups.ownerGroups
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
var tokenModelLimit map[string]bool
if modelLimitEnable { if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
var tokenModelLimit map[string]bool
if ok { if ok {
tokenModelLimit = s.(map[string]bool) tokenModelLimit, _ = s.(map[string]bool)
} else { }
if tokenModelLimit == nil {
tokenModelLimit = map[string]bool{} tokenModelLimit = map[string]bool{}
} }
for allowModel, _ := range tokenModelLimit { }
if !acceptUnsetRatioModel { models := service.GetGroupsEnabledModels(ownerGroups)
if !helper.HasModelBillingConfig(allowModel) { for _, modelName := range models {
continue if modelLimitEnable {
} matchingName := ratio_setting.FormatMatchingModelName(modelName)
if !tokenModelLimit[modelName] && !tokenModelLimit[matchingName] {
continue
} }
userModelNames = append(userModelNames, allowModel)
} }
} else { if !acceptUnsetRatioModel && !helper.HasModelBillingConfig(modelName) {
models := service.GetGroupsEnabledModels(ownerGroups) continue
for _, modelName := range models {
if !acceptUnsetRatioModel {
if !helper.HasModelBillingConfig(modelName) {
continue
}
}
userModelNames = append(userModelNames, modelName)
} }
userModelNames = append(userModelNames, modelName)
} }
ownerByModel := map[string]string{} ownerByModel := map[string]string{}
...@@ -276,11 +273,17 @@ func ListModels(c *gin.Context, modelType int) { ...@@ -276,11 +273,17 @@ func ListModels(c *gin.Context, modelType int) {
Type: "model", Type: "model",
} }
} }
firstID := ""
lastID := ""
if len(useranthropicModels) > 0 {
firstID = useranthropicModels[0].ID
lastID = useranthropicModels[len(useranthropicModels)-1].ID
}
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"data": useranthropicModels, "data": useranthropicModels,
"first_id": useranthropicModels[0].ID, "first_id": firstID,
"has_more": false, "has_more": false,
"last_id": useranthropicModels[len(useranthropicModels)-1].ID, "last_id": lastID,
}) })
case constant.ChannelTypeGemini: case constant.ChannelTypeGemini:
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels)) userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
......
...@@ -402,7 +402,13 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) { ...@@ -402,7 +402,13 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
"zz-token-tiered-visible-model": `tier("base", p * 1 + c * 2)`, "zz-token-tiered-visible-model": `tier("base", p * 1 + c * 2)`,
"zz-token-tiered-empty-expr-model": "", "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() recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder) ctx, _ := gin.CreateTestContext(recorder)
...@@ -425,6 +431,68 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) { ...@@ -425,6 +431,68 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
require.NotContains(t, ids, "zz-token-unpriced-model") 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) { func TestCheckUpdatePasswordRequiresCurrentPassword(t *testing.T) {
db := setupModelListControllerTestDB(t) db := setupModelListControllerTestDB(t)
hashedPassword, err := common.Password2Hash("CurrentPassword123") hashedPassword, err := common.Password2Hash("CurrentPassword123")
......
package controller package controller
import ( import (
"fmt"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "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/gin-gonic/gin"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -83,3 +86,33 @@ func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) { ...@@ -83,3 +86,33 @@ func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) {
require.Equal(t, "vip", groups.tokenGroup) require.Equal(t, "vip", groups.tokenGroup)
require.Equal(t, []string{"vip"}, groups.ownerGroups) 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)
}
...@@ -7,30 +7,115 @@ import ( ...@@ -7,30 +7,115 @@ import (
"strings" "strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin" "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 { if token == nil {
return nil return nil
} }
maskedToken := *token maskedToken := *token
maskedToken.Key = token.GetMaskedKey() 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 { func buildMaskedTokenResponses(tokens []*model.Token) []*tokenResponse {
maskedTokens := make([]*model.Token, 0, len(tokens)) maskedTokens := make([]*tokenResponse, 0, len(tokens))
for _, token := range tokens { for _, token := range tokens {
maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token)) maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
} }
return maskedTokens 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) { func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id") userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c) pageInfo := common.GetPageQuery(c)
...@@ -77,6 +162,18 @@ func GetToken(c *gin.Context) { ...@@ -77,6 +162,18 @@ func GetToken(c *gin.Context) {
common.ApiSuccess(c, buildMaskedTokenResponse(token)) 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) { func GetTokenKey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id") userId := c.GetInt("id")
...@@ -165,12 +262,13 @@ func GetTokenUsage(c *gin.Context) { ...@@ -165,12 +262,13 @@ func GetTokenUsage(c *gin.Context) {
} }
func AddToken(c *gin.Context) { func AddToken(c *gin.Context) {
token := model.Token{} request := tokenRequest{}
err := c.ShouldBindJSON(&token) err := c.ShouldBindJSON(&request)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
token := request.Token
if len(token.Name) > 50 { if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return return
...@@ -201,6 +299,14 @@ func AddToken(c *gin.Context) { ...@@ -201,6 +299,14 @@ func AddToken(c *gin.Context) {
}) })
return return
} }
if token.Group == "auto" {
if !setTokenAutoGroups(c, &token, request.AutoGroups.Groups) {
return
}
} else {
token.CrossGroupRetry = false
_ = token.SetAutoGroups(nil)
}
key, err := common.GenerateKey() key, err := common.GenerateKey()
if err != nil { if err != nil {
common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed) common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
...@@ -221,6 +327,7 @@ func AddToken(c *gin.Context) { ...@@ -221,6 +327,7 @@ func AddToken(c *gin.Context) {
AllowIps: token.AllowIps, AllowIps: token.AllowIps,
Group: token.Group, Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry, CrossGroupRetry: token.CrossGroupRetry,
AutoGroups: token.AutoGroups,
} }
err = cleanToken.Insert() err = cleanToken.Insert()
if err != nil { if err != nil {
...@@ -250,12 +357,13 @@ func DeleteToken(c *gin.Context) { ...@@ -250,12 +357,13 @@ func DeleteToken(c *gin.Context) {
func UpdateToken(c *gin.Context) { func UpdateToken(c *gin.Context) {
userId := c.GetInt("id") userId := c.GetInt("id")
statusOnly := c.Query("status_only") statusOnly := c.Query("status_only")
token := model.Token{} request := tokenRequest{}
err := c.ShouldBindJSON(&token) err := c.ShouldBindJSON(&request)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
token := request.Token
if len(token.Name) > 50 { if len(token.Name) > 50 {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return return
...@@ -299,6 +407,14 @@ func UpdateToken(c *gin.Context) { ...@@ -299,6 +407,14 @@ func UpdateToken(c *gin.Context) {
cleanToken.AllowIps = token.AllowIps cleanToken.AllowIps = token.AllowIps
cleanToken.Group = token.Group cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry 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() err = cleanToken.Update()
if err != nil { if err != nil {
......
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)
}
...@@ -273,6 +273,34 @@ func getTokenKeyColumnType(t *testing.T, db *gorm.DB, dialect string) string { ...@@ -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) { func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect string, managedTokensTable *bool) {
t.Helper() t.Helper()
...@@ -314,6 +342,12 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin ...@@ -314,6 +342,12 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" { if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" {
t.Fatalf("expected migrated key column type varchar(128), got %q", got) 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 var migratedToken model.Token
if err := db.First(&migratedToken, "name = ?", "legacy-token").Error; err != nil { 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 ...@@ -325,6 +359,9 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
if migratedToken.Name != "legacy-token" { if migratedToken.Name != "legacy-token" {
t.Fatalf("expected migrated token name to be preserved, got %q", migratedToken.Name) 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{ inserted := model.Token{
UserId: 8, UserId: 8,
...@@ -362,6 +399,9 @@ func TestTokenAutoMigrateUsesVarchar128KeyColumn(t *testing.T) { ...@@ -362,6 +399,9 @@ func TestTokenAutoMigrateUsesVarchar128KeyColumn(t *testing.T) {
if got := getTokenKeyColumnType(t, db, "sqlite"); got != "varchar(128)" { if got := getTokenKeyColumnType(t, db, "sqlite"); got != "varchar(128)" {
t.Fatalf("expected key column type varchar(128), got %q", got) 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) { func TestTokenMigrationFromChar48ToVarchar128(t *testing.T) {
......
...@@ -55,6 +55,9 @@ const ( ...@@ -55,6 +55,9 @@ const (
MsgTokenExhausted = "token.exhausted" MsgTokenExhausted = "token.exhausted"
MsgTokenStatusUnavailable = "token.status_unavailable" MsgTokenStatusUnavailable = "token.status_unavailable"
MsgTokenDbError = "token.db_error" MsgTokenDbError = "token.db_error"
MsgTokenAutoGroupsTooMany = "token.auto_groups_too_many"
MsgTokenAutoGroupsDuplicate = "token.auto_groups_duplicate"
MsgTokenAutoGroupsInvalid = "token.auto_groups_invalid"
) )
// Redemption related messages // Redemption related messages
......
...@@ -47,6 +47,9 @@ token.expired: "This token has expired" ...@@ -47,6 +47,9 @@ token.expired: "This token has expired"
token.exhausted: "This token quota is exhausted TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]" token.exhausted: "This token quota is exhausted TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "This token status is unavailable" token.status_unavailable: "This token status is unavailable"
token.db_error: "Invalid token, database query error, please contact administrator" 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 messages
redemption.name_length: "Redemption code name length must be between 1-20" redemption.name_length: "Redemption code name length must be between 1-20"
......
...@@ -48,6 +48,9 @@ token.expired: "该令牌已过期" ...@@ -48,6 +48,9 @@ token.expired: "该令牌已过期"
token.exhausted: "该令牌额度已用尽 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]" token.exhausted: "该令牌额度已用尽 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "该令牌状态不可用" token.status_unavailable: "该令牌状态不可用"
token.db_error: "无效的令牌,数据库查询出错,请联系管理员" 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 messages
redemption.name_length: "兑换码名称长度必须在1-20之间" redemption.name_length: "兑换码名称长度必须在1-20之间"
......
...@@ -48,6 +48,9 @@ token.expired: "該令牌已過期" ...@@ -48,6 +48,9 @@ token.expired: "該令牌已過期"
token.exhausted: "該令牌額度已用盡 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]" token.exhausted: "該令牌額度已用盡 TokenStatusExhausted[sk-{{.Prefix}}***{{.Suffix}}]"
token.status_unavailable: "該令牌狀態不可用" token.status_unavailable: "該令牌狀態不可用"
token.db_error: "無效的令牌,資料庫查詢出錯,請聯繫管理員" 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 messages
redemption.name_length: "兌換碼名稱長度必須在1-20之間" redemption.name_length: "兌換碼名稱長度必須在1-20之間"
......
...@@ -502,6 +502,16 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e ...@@ -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.ContextKeyTokenGroup, token.Group)
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry) 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 len(parts) > 1 {
if model.IsAdmin(token.UserId) { if model.IsAdmin(token.UserId) {
c.Set("specific_channel_id", parts[1]) c.Set("specific_channel_id", parts[1])
......
...@@ -109,7 +109,7 @@ func Distribute() func(c *gin.Context) { ...@@ -109,7 +109,7 @@ func Distribute() func(c *gin.Context) {
channelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) { channelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) {
if usingGroup == "auto" { if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup) autoGroups := service.GetRequestAutoGroups(c, userGroup)
for _, g := range autoGroups { for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g selectGroup = g
......
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)
}
...@@ -120,6 +120,7 @@ func InitOptionMap() { ...@@ -120,6 +120,7 @@ func InitOptionMap() {
common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
common.OptionMap["MaxTokenAutoGroups"] = strconv.Itoa(setting.GetMaxTokenAutoGroups())
common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString() common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString()
common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = "" common.OptionMap["GitHubClientSecret"] = ""
...@@ -208,6 +209,9 @@ func validateOptionValue(key string, value string) error { ...@@ -208,6 +209,9 @@ func validateOptionValue(key string, value string) error {
if key == operation_setting.ToolPriceOptionKey { if key == operation_setting.ToolPriceOptionKey {
return operation_setting.ValidateToolPricesJSON(value) return operation_setting.ValidateToolPricesJSON(value)
} }
if key == "MaxTokenAutoGroups" {
return setting.ValidateMaxTokenAutoGroups(value)
}
return nil return nil
} }
...@@ -413,6 +417,8 @@ func updateOptionMap(key string, value string) (err error) { ...@@ -413,6 +417,8 @@ func updateOptionMap(key string, value string) (err error) {
err = setting.UpdateChatsByJsonString(value) err = setting.UpdateChatsByJsonString(value)
case "AutoGroups": case "AutoGroups":
err = setting.UpdateAutoGroupsByJsonString(value) err = setting.UpdateAutoGroupsByJsonString(value)
case "MaxTokenAutoGroups":
err = setting.UpdateMaxTokenAutoGroups(value)
case "CustomCallbackAddress": case "CustomCallbackAddress":
operation_setting.CustomCallbackAddress = value operation_setting.CustomCallbackAddress = value
case "EpayId": case "EpayId":
......
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"))
}
...@@ -28,9 +28,34 @@ type Token struct { ...@@ -28,9 +28,34 @@ type Token struct {
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"` Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效 CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
AutoGroups string `json:"-" gorm:"type:text"`
DeletedAt gorm.DeletedAt `gorm:"index"` 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() { func (token *Token) Clean() {
token.Key = "" token.Key = ""
} }
...@@ -291,18 +316,16 @@ func (token *Token) Insert() error { ...@@ -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 // Update Make sure your token's fields is completed, because this will update non-zero values
func (token *Token) Update() (err error) { 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", 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 return err
} }
......
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)
}
...@@ -238,6 +238,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -238,6 +238,7 @@ func SetApiRouter(router *gin.Engine) {
{ {
tokenRoute.GET("/", controller.GetAllTokens) tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens) tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
tokenRoute.GET("/auto-groups", controller.GetTokenAutoGroups)
tokenRoute.GET("/:id", controller.GetToken) tokenRoute.GET("/:id", controller.GetToken)
tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey) tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey)
tokenRoute.POST("/", controller.AddToken) tokenRoute.POST("/", controller.AddToken)
......
...@@ -7,7 +7,6 @@ import ( ...@@ -7,7 +7,6 @@ import (
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
...@@ -88,10 +87,10 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, ...@@ -88,10 +87,10 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup) userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup)
if param.TokenGroup == "auto" { 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") return nil, selectGroup, errors.New("auto groups is not enabled")
} }
autoGroups := GetUserAutoGroup(userGroup)
// startGroupIndex: the group index to start searching from // startGroupIndex: the group index to start searching from
// startGroupIndex: 开始搜索的分组索引 // startGroupIndex: 开始搜索的分组索引
......
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))
}
...@@ -3,9 +3,12 @@ package service ...@@ -3,9 +3,12 @@ package service
import ( import (
"strings" "strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
) )
func GetUserUsableGroups(userGroup string) map[string]string { func GetUserUsableGroups(userGroup string) map[string]string {
...@@ -42,18 +45,67 @@ func GroupInUserUsableGroups(userGroup, groupName string) bool { ...@@ -42,18 +45,67 @@ func GroupInUserUsableGroups(userGroup, groupName string) bool {
return ok return ok
} }
func IsUserSelectableGroup(userGroup, groupName string) bool {
if groupName == "" || groupName == "auto" {
return false
}
return GroupInUserUsableGroups(userGroup, groupName) && ratio_setting.ContainsGroupRatio(groupName)
}
// GetUserAutoGroup 根据用户分组获取自动分组设置 // GetUserAutoGroup 根据用户分组获取自动分组设置
func GetUserAutoGroup(userGroup string) []string { func GetUserAutoGroup(userGroup string) []string {
groups := GetUserUsableGroups(userGroup)
autoGroups := make([]string, 0) autoGroups := make([]string, 0)
seen := make(map[string]struct{})
for _, group := range setting.GetAutoGroups() { for _, group := range setting.GetAutoGroups() {
if _, ok := groups[group]; ok { if !IsUserSelectableGroup(userGroup, group) {
autoGroups = append(autoGroups, group) continue
}
if _, ok := seen[group]; ok {
continue
} }
seen[group] = struct{}{}
autoGroups = append(autoGroups, group)
} }
return autoGroups 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 顺序获取各分组启用的模型并去重 // GetGroupsEnabledModels 按 groups 顺序获取各分组启用的模型并去重
func GetGroupsEnabledModels(groups []string) []string { func GetGroupsEnabledModels(groups []string) []string {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
......
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)
}
package setting package setting
import ( import (
"fmt"
"strconv"
"sync/atomic"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
) )
const DefaultMaxTokenAutoGroups = 5
var autoGroups = []string{ var autoGroups = []string{
"default", "default",
} }
var DefaultUseAutoGroup = false var DefaultUseAutoGroup = false
var maxTokenAutoGroups atomic.Int64
func init() {
maxTokenAutoGroups.Store(DefaultMaxTokenAutoGroups)
}
func ContainsAutoGroup(group string) bool { func ContainsAutoGroup(group string) bool {
for _, autoGroup := range autoGroups { for _, autoGroup := range autoGroups {
if autoGroup == group { if autoGroup == group {
...@@ -35,3 +47,24 @@ func AutoGroups2JsonString() string { ...@@ -35,3 +47,24 @@ func AutoGroups2JsonString() string {
func GetAutoGroups() []string { func GetAutoGroups() []string {
return autoGroups 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
}
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())
})
}
}
...@@ -25,6 +25,7 @@ import type { ...@@ -25,6 +25,7 @@ import type {
GetApiKeysResponse, GetApiKeysResponse,
SearchApiKeysParams, SearchApiKeysParams,
ApiKeyFormData, ApiKeyFormData,
TokenAutoGroupsConfig,
} from './types' } from './types'
// ============================================================================ // ============================================================================
...@@ -60,6 +61,14 @@ export async function getApiKey(id: number): Promise<ApiResponse<ApiKey>> { ...@@ -60,6 +61,14 @@ export async function getApiKey(id: number): Promise<ApiResponse<ApiKey>> {
return res.data 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 // Create a new API key
export async function createApiKey( export async function createApiKey(
data: ApiKeyFormData data: ApiKeyFormData
......
/*
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()
})
})
/*
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)
})
})
/*
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' ...@@ -20,7 +20,6 @@ import { Check, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Command, Command,
...@@ -35,8 +34,15 @@ import { ...@@ -35,8 +34,15 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import {
AUTO_GROUP_FRAME_CLASS_NAME,
AutoGroupFlowBorder,
GroupRatioBadge,
} from './auto-group-visuals'
export type ApiKeyGroupOption = { export type ApiKeyGroupOption = {
value: string value: string
label: string label: string
...@@ -52,50 +58,6 @@ type ApiKeyGroupComboboxProps = { ...@@ -52,50 +58,6 @@ type ApiKeyGroupComboboxProps = {
disabled?: boolean 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({ export function ApiKeyGroupCombobox({
options, options,
value, value,
...@@ -106,7 +68,9 @@ export function ApiKeyGroupCombobox({ ...@@ -106,7 +68,9 @@ export function ApiKeyGroupCombobox({
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('') const [searchValue, setSearchValue] = useState('')
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const selectedOption = options.find((option) => option.value === value) const selectedOption = options.find((option) => option.value === value)
const isAutoSelected = selectedOption?.value === 'auto'
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
const search = searchValue.trim().toLowerCase() const search = searchValue.trim().toLowerCase()
...@@ -138,11 +102,22 @@ export function ApiKeyGroupCombobox({ ...@@ -138,11 +102,22 @@ export function ApiKeyGroupCombobox({
variant='outline' variant='outline'
role='combobox' role='combobox'
aria-expanded={open} aria-expanded={open}
data-auto-group-effect={isAutoSelected ? 'trigger' : undefined}
disabled={disabled} 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='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
<span className='min-w-0'> <span className='min-w-0'>
<span className='block truncate font-medium'> <span className='block truncate font-medium'>
...@@ -155,10 +130,17 @@ export function ApiKeyGroupCombobox({ ...@@ -155,10 +130,17 @@ export function ApiKeyGroupCombobox({
)} )}
</span> </span>
<span className='hidden sm:block'> <span className='hidden sm:block'>
<GroupRatioBadge ratio={selectedOption?.ratio} /> <GroupRatioBadge
ratio={selectedOption?.ratio}
isAuto={isAutoSelected}
shouldReduceMotion={shouldReduceMotion}
/>
</span> </span>
</span> </span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' /> <ChevronsUpDown
aria-hidden='true'
className='size-4 shrink-0 opacity-50'
/>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <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' 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,32 +157,54 @@ export function ApiKeyGroupCombobox({ ...@@ -175,32 +157,54 @@ export function ApiKeyGroupCombobox({
<CommandList className='max-h-[360px]'> <CommandList className='max-h-[360px]'>
<CommandEmpty>{t('No group found.')}</CommandEmpty> <CommandEmpty>{t('No group found.')}</CommandEmpty>
<CommandGroup> <CommandGroup>
{filteredOptions.map((option) => ( {filteredOptions.map((option) => {
<CommandItem const isAutoOption = option.value === 'auto'
key={option.value}
value={option.value} return (
onSelect={() => handleSelect(option.value)} <CommandItem
className='data-[selected=true]:bg-muted items-start gap-3 rounded-lg px-3 py-3 transition-colors' key={option.value}
> value={option.value}
<Check data-auto-group-effect={isAutoOption ? 'option' : undefined}
onSelect={() => handleSelect(option.value)}
className={cn( 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',
value === option.value ? 'opacity-100' : 'opacity-0' isAutoOption &&
cn(
AUTO_GROUP_FRAME_CLASS_NAME,
'border-primary/35 data-[selected=true]:border-primary/55'
)
)} )}
/> >
<span className='min-w-0 flex-1'> {isAutoOption && (
<span className='block truncate font-medium'> <AutoGroupFlowBorder
{option.label} shouldReduceMotion={shouldReduceMotion}
</span> />
{option.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{option.desc}
</span>
)} )}
</span> <Check
<GroupRatioBadge ratio={option.ratio} /> aria-hidden='true'
</CommandItem> className={cn(
))} 'mt-0.5 size-4',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
<span className='min-w-0 flex-1'>
<span className='block truncate font-medium'>
{option.label}
</span>
{option.desc && (
<span className='text-muted-foreground block truncate text-xs'>
{option.desc}
</span>
)}
</span>
<GroupRatioBadge
ratio={option.ratio}
isAuto={isAutoOption}
shouldReduceMotion={shouldReduceMotion}
/>
</CommandItem>
)
})}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
......
...@@ -20,8 +20,6 @@ import { useQuery } from '@tanstack/react-query' ...@@ -20,8 +20,6 @@ import { useQuery } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' 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 { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
...@@ -30,6 +28,7 @@ import { ...@@ -30,6 +28,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { useMediaQuery } from '@/hooks'
import { toIntlLocale } from '@/i18n/languages' import { toIntlLocale } from '@/i18n/languages'
import { getUserGroups } from '@/lib/api' import { getUserGroups } from '@/lib/api'
import dayjs from '@/lib/dayjs' import dayjs from '@/lib/dayjs'
...@@ -38,6 +37,7 @@ import { cn } from '@/lib/utils' ...@@ -38,6 +37,7 @@ import { cn } from '@/lib/utils'
import { API_KEY_STATUSES } from '../constants' import { API_KEY_STATUSES } from '../constants'
import type { ApiKey } from '../types' import type { ApiKey } from '../types'
import { ApiKeyGroupCell } from './api-key-group-cell'
import { ApiKeyTimestampCell } from './api-key-timestamp-cell' import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
import { import {
ApiKeyCell, ApiKeyCell,
...@@ -53,16 +53,16 @@ function getQuotaProgressColor(percentage: number): string { ...@@ -53,16 +53,16 @@ function getQuotaProgressColor(percentage: number): string {
return '[&_[data-slot=progress-indicator]]:bg-emerald-500' return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
} }
function useGroupRatios(): Record<string, number> { function useGroupRatios(): Record<string, number | string> {
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['user-groups'], queryKey: ['user-groups'],
queryFn: getUserGroups, queryFn: getUserGroups,
staleTime: 0, staleTime: 0,
select: (res) => { select: (res) => {
if (!res.success || !res.data) return {} 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)) { 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 ratios[group] = info.ratio
} }
} }
...@@ -76,6 +76,7 @@ function useGroupRatios(): Record<string, number> { ...@@ -76,6 +76,7 @@ function useGroupRatios(): Record<string, number> {
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] { export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const groupRatios = useGroupRatios() const groupRatios = useGroupRatios()
const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language) const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const justNowLabel = t('Just now') const justNowLabel = t('Just now')
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf() const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
...@@ -195,44 +196,16 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] { ...@@ -195,44 +196,16 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
cell: ({ row }) => { cell: ({ row }) => {
const apiKey = row.original const apiKey = row.original
const group = row.getValue('group') as string 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}
/>
)}
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
{t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
</span>
</TooltipContent>
</Tooltip>
)
}
return ( return (
<TruncatedCell <ApiKeyGroupCell
className='-ml-1.5' group={group}
tooltipContent={group || '-'} ratio={groupRatios[group]}
tooltipClassName='break-all' crossGroupRetry={apiKey.cross_group_retry}
> shouldReduceMotion={shouldReduceMotion}
<GroupBadge group={group} ratio={ratio} /> />
</TruncatedCell>
) )
}, },
size: 160, size: 220,
meta: { mobileHidden: true }, meta: { mobileHidden: true },
}, },
{ {
......
/*
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>
)
}
/*
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'
)
})
})
...@@ -22,13 +22,16 @@ import { z } from 'zod' ...@@ -22,13 +22,16 @@ import { z } from 'zod'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format' import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import { DEFAULT_GROUP } from '../constants' import { DEFAULT_GROUP } from '../constants'
import { type ApiKeyFormData, type ApiKey } from '../types' import type { ApiKey, ApiKeyFormData } from '../types'
// ============================================================================ // ============================================================================
// Form Schema // 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 return z
.object({ .object({
name: z.string().min(1, t('Please enter a name')), name: z.string().min(1, t('Please enter a name')),
...@@ -38,10 +41,45 @@ export function getApiKeyFormSchema(t: TFunction) { ...@@ -38,10 +41,45 @@ export function getApiKeyFormSchema(t: TFunction) {
model_limits: z.array(z.string()), model_limits: z.array(z.string()),
allow_ips: z.string().optional(), allow_ips: z.string().optional(),
group: 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(), cross_group_retry: z.boolean().optional(),
tokenCount: z.number().min(1).optional(), tokenCount: z.number().min(1).optional(),
}) })
.superRefine((data, ctx) => { .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) { if (data.unlimited_quota) {
return return
} }
...@@ -73,6 +111,8 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = { ...@@ -73,6 +111,8 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = {
model_limits: [], model_limits: [],
allow_ips: '', allow_ips: '',
group: DEFAULT_GROUP, group: DEFAULT_GROUP,
auto_groups_mode: 'inherit',
auto_groups: [],
cross_group_retry: true, cross_group_retry: true,
tokenCount: 1, tokenCount: 1,
} }
...@@ -83,6 +123,8 @@ export function getApiKeyFormDefaultValues( ...@@ -83,6 +123,8 @@ export function getApiKeyFormDefaultValues(
return { return {
...API_KEY_FORM_DEFAULT_VALUES, ...API_KEY_FORM_DEFAULT_VALUES,
group: defaultUseAutoGroup ? 'auto' : DEFAULT_GROUP, group: defaultUseAutoGroup ? 'auto' : DEFAULT_GROUP,
auto_groups_mode: 'inherit',
auto_groups: [],
cross_group_retry: defaultUseAutoGroup, cross_group_retry: defaultUseAutoGroup,
} }
} }
...@@ -110,6 +152,10 @@ export function transformFormDataToPayload( ...@@ -110,6 +152,10 @@ export function transformFormDataToPayload(
model_limits: data.model_limits.join(','), model_limits: data.model_limits.join(','),
allow_ips: data.allow_ips || '', allow_ips: data.allow_ips || '',
group: data.group || '', 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, cross_group_retry: data.group === 'auto' ? !!data.cross_group_retry : false,
} }
} }
...@@ -118,8 +164,17 @@ export function transformFormDataToPayload( ...@@ -118,8 +164,17 @@ export function transformFormDataToPayload(
* Transform API key data to form defaults * Transform API key data to form defaults
*/ */
export function transformApiKeyToFormDefaults( export function transformApiKeyToFormDefaults(
apiKey: ApiKey apiKey: ApiKey,
availableAutoGroups: string[] = [],
maxAutoGroups = 5
): ApiKeyFormValues { ): 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 { return {
name: apiKey.name, name: apiKey.name,
remain_quota_dollars: apiKey.unlimited_quota remain_quota_dollars: apiKey.unlimited_quota
...@@ -135,6 +190,8 @@ export function transformApiKeyToFormDefaults( ...@@ -135,6 +190,8 @@ export function transformApiKeyToFormDefaults(
: [], : [],
allow_ips: apiKey.allow_ips || '', allow_ips: apiKey.allow_ips || '',
group: apiKey.group || DEFAULT_GROUP, group: apiKey.group || DEFAULT_GROUP,
auto_groups_mode: autoGroupsMode,
auto_groups: autoGroups,
cross_group_retry: !!apiKey.cross_group_retry, cross_group_retry: !!apiKey.cross_group_retry,
tokenCount: 1, tokenCount: 1,
} }
......
...@@ -34,6 +34,7 @@ export const apiKeySchema = z.object({ ...@@ -34,6 +34,7 @@ export const apiKeySchema = z.object({
created_time: z.number(), created_time: z.number(),
accessed_time: z.number(), accessed_time: z.number(),
group: z.string().nullish().default(''), group: z.string().nullish().default(''),
auto_groups: z.array(z.string()).nullish().default(null),
cross_group_retry: z cross_group_retry: z
.preprocess((v) => { .preprocess((v) => {
if (v === 1) return true if (v === 1) return true
...@@ -91,9 +92,15 @@ export interface ApiKeyFormData { ...@@ -91,9 +92,15 @@ export interface ApiKeyFormData {
model_limits: string model_limits: string
allow_ips: string allow_ips: string
group: string group: string
auto_groups: string[]
cross_group_retry: boolean cross_group_retry: boolean
} }
export interface TokenAutoGroupsConfig {
groups: string[]
max_count: number
}
// ============================================================================ // ============================================================================
// Dialog Types // Dialog Types
// ============================================================================ // ============================================================================
......
...@@ -319,6 +319,7 @@ export function ModelMutateDrawer({ ...@@ -319,6 +319,7 @@ export function ModelMutateDrawer({
UserUsableGroups: '', UserUsableGroups: '',
GroupGroupRatio: '', GroupGroupRatio: '',
AutoGroups: '', AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false, DefaultUseAutoGroup: false,
CreateCacheRatio: '', CreateCacheRatio: '',
'group_ratio_setting.group_special_usable_group': '{}', 'group_ratio_setting.group_special_usable_group': '{}',
......
...@@ -56,6 +56,7 @@ const defaultBillingSettings: BillingSettings = { ...@@ -56,6 +56,7 @@ const defaultBillingSettings: BillingSettings = {
UserUsableGroups: '', UserUsableGroups: '',
GroupGroupRatio: '', GroupGroupRatio: '',
AutoGroups: '', AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false, DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}', 'group_ratio_setting.group_special_usable_group': '{}',
PayAddress: '', PayAddress: '',
......
...@@ -46,6 +46,7 @@ const getGroupDefaults = (settings: BillingSettings) => ({ ...@@ -46,6 +46,7 @@ const getGroupDefaults = (settings: BillingSettings) => ({
UserUsableGroups: settings.UserUsableGroups, UserUsableGroups: settings.UserUsableGroups,
GroupGroupRatio: settings.GroupGroupRatio, GroupGroupRatio: settings.GroupGroupRatio,
AutoGroups: settings.AutoGroups, AutoGroups: settings.AutoGroups,
MaxTokenAutoGroups: settings.MaxTokenAutoGroups,
DefaultUseAutoGroup: settings.DefaultUseAutoGroup, DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
GroupSpecialUsableGroup: GroupSpecialUsableGroup:
settings['group_ratio_setting.group_special_usable_group'], settings['group_ratio_setting.group_special_usable_group'],
......
/*
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 { ...@@ -43,6 +43,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
...@@ -59,6 +60,7 @@ import { ...@@ -59,6 +60,7 @@ import {
} from '../components/settings-form-layout' } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context' import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { safeJsonParse } from '../utils/json-parser' import { safeJsonParse } from '../utils/json-parser'
import { safeNumberFieldProps } from '../utils/numeric-field'
import { GroupRatioVisualEditor } from './group-ratio-visual-editor' import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor' import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
...@@ -68,6 +70,7 @@ type GroupFormValues = { ...@@ -68,6 +70,7 @@ type GroupFormValues = {
UserUsableGroups: string UserUsableGroups: string
GroupGroupRatio: string GroupGroupRatio: string
AutoGroups: string AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean DefaultUseAutoGroup: boolean
GroupSpecialUsableGroup: string GroupSpecialUsableGroup: string
} }
...@@ -169,6 +172,34 @@ export const GroupRatioForm = memo(function GroupRatioForm({ ...@@ -169,6 +172,34 @@ export const GroupRatioForm = memo(function GroupRatioForm({
userUsableGroups={form.watch('UserUsableGroups')} userUsableGroups={form.watch('UserUsableGroups')}
groupGroupRatio={form.watch('GroupGroupRatio')} groupGroupRatio={form.watch('GroupGroupRatio')}
autoGroups={form.watch('AutoGroups')} 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')} groupSpecialUsableGroup={form.watch('GroupSpecialUsableGroup')}
onChange={(field, value) => onChange={(field, value) =>
handleFieldChange(field as keyof GroupFormValues, value) handleFieldChange(field as keyof GroupFormValues, value)
...@@ -341,6 +372,31 @@ export const GroupRatioForm = memo(function GroupRatioForm({ ...@@ -341,6 +372,31 @@ export const GroupRatioForm = memo(function GroupRatioForm({
<FormField <FormField
control={form.control} 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' name='GroupSpecialUsableGroup'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
......
...@@ -24,7 +24,14 @@ import { ...@@ -24,7 +24,14 @@ import {
Plus, Plus,
Trash2, Trash2,
} from 'lucide-react' } 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 { useTranslation } from 'react-i18next'
import { StaticDataTable } from '@/components/data-table/static/static-data-table' import { StaticDataTable } from '@/components/data-table/static/static-data-table'
...@@ -76,6 +83,7 @@ type GroupRatioVisualEditorProps = { ...@@ -76,6 +83,7 @@ type GroupRatioVisualEditorProps = {
userUsableGroups: string userUsableGroups: string
groupGroupRatio: string groupGroupRatio: string
autoGroups: string autoGroups: string
maxTokenAutoGroupsField: ReactNode
groupSpecialUsableGroup: string groupSpecialUsableGroup: string
onChange: (field: string, value: string) => void onChange: (field: string, value: string) => void
} }
...@@ -257,6 +265,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({ ...@@ -257,6 +265,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
userUsableGroups, userUsableGroups,
groupGroupRatio, groupGroupRatio,
autoGroups, autoGroups,
maxTokenAutoGroupsField,
groupSpecialUsableGroup, groupSpecialUsableGroup,
onChange, onChange,
}: GroupRatioVisualEditorProps) { }: GroupRatioVisualEditorProps) {
...@@ -351,6 +360,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({ ...@@ -351,6 +360,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='space-y-4'> <div className='space-y-4'>
{maxTokenAutoGroupsField}
<GroupNameSelect <GroupNameSelect
options={autoGroupCandidates} options={autoGroupCandidates}
value={null} value={null}
......
...@@ -60,6 +60,7 @@ const defaultModelSettings: ModelSettings = { ...@@ -60,6 +60,7 @@ const defaultModelSettings: ModelSettings = {
UserUsableGroups: '', UserUsableGroups: '',
GroupGroupRatio: '', GroupGroupRatio: '',
AutoGroups: '', AutoGroups: '',
MaxTokenAutoGroups: 5,
DefaultUseAutoGroup: false, DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}', 'group_ratio_setting.group_special_usable_group': '{}',
RetryTimes: 0, RetryTimes: 0,
......
...@@ -31,6 +31,7 @@ import { resetModelRatios } from '../api' ...@@ -31,6 +31,7 @@ import { resetModelRatios } from '../api'
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context' import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section' import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option' import { useUpdateOption } from '../hooks/use-update-option'
import { positiveIntegerSchema } from '../utils/numeric-field'
import { GroupRatioForm } from './group-ratio-form' import { GroupRatioForm } from './group-ratio-form'
import { ModelRatioForm } from './model-ratio-form' import { ModelRatioForm } from './model-ratio-form'
import { ToolPriceSettings } from './tool-price-settings' import { ToolPriceSettings } from './tool-price-settings'
...@@ -130,6 +131,7 @@ const createGroupSchema = (t: Translate) => ...@@ -130,6 +131,7 @@ const createGroupSchema = (t: Translate) =>
parsed.every((item) => typeof item === 'string'), parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of group identifiers', predicateMessage: 'Expected a JSON array of group identifiers',
}), }),
MaxTokenAutoGroups: positiveIntegerSchema(t('Enter a positive integer')),
DefaultUseAutoGroup: z.boolean(), DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: createJsonStringField(t), GroupSpecialUsableGroup: createJsonStringField(t),
}) })
...@@ -204,6 +206,7 @@ export function RatioSettingsCard({ ...@@ -204,6 +206,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups), UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio), GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups), AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
MaxTokenAutoGroups: groupDefaults.MaxTokenAutoGroups,
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup, DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString( GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup groupDefaults.GroupSpecialUsableGroup
...@@ -290,6 +293,7 @@ export function RatioSettingsCard({ ...@@ -290,6 +293,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups), UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio), GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups), AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
MaxTokenAutoGroups: groupDefaults.MaxTokenAutoGroups,
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup, DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString( GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup groupDefaults.GroupSpecialUsableGroup
...@@ -360,6 +364,7 @@ export function RatioSettingsCard({ ...@@ -360,6 +364,7 @@ export function RatioSettingsCard({
UserUsableGroups: normalizeJsonString(values.UserUsableGroups), UserUsableGroups: normalizeJsonString(values.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio), GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio),
AutoGroups: normalizeJsonString(values.AutoGroups), AutoGroups: normalizeJsonString(values.AutoGroups),
MaxTokenAutoGroups: values.MaxTokenAutoGroups,
DefaultUseAutoGroup: values.DefaultUseAutoGroup, DefaultUseAutoGroup: values.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString( GroupSpecialUsableGroup: normalizeJsonString(
values.GroupSpecialUsableGroup values.GroupSpecialUsableGroup
...@@ -382,6 +387,8 @@ export function RatioSettingsCard({ ...@@ -382,6 +387,8 @@ export function RatioSettingsCard({
const apiKey = apiKeyMap[key] || key const apiKey = apiKeyMap[key] || key
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] }) await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
} }
groupNormalizedDefaults.current = normalized
}, },
[updateOption] [updateOption]
) )
......
...@@ -223,6 +223,7 @@ export type ModelSettings = { ...@@ -223,6 +223,7 @@ export type ModelSettings = {
UserUsableGroups: string UserUsableGroups: string
GroupGroupRatio: string GroupGroupRatio: string
AutoGroups: string AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string 'group_ratio_setting.group_special_usable_group': string
RetryTimes: number RetryTimes: number
...@@ -277,6 +278,7 @@ export type BillingSettings = { ...@@ -277,6 +278,7 @@ export type BillingSettings = {
UserUsableGroups: string UserUsableGroups: string
GroupGroupRatio: string GroupGroupRatio: string
AutoGroups: string AutoGroups: string
MaxTokenAutoGroups: number
DefaultUseAutoGroup: boolean DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string 'group_ratio_setting.group_special_usable_group': string
PayAddress: string PayAddress: string
......
...@@ -22,6 +22,11 @@ import type { ...@@ -22,6 +22,11 @@ import type {
FieldPath, FieldPath,
FieldValues, FieldValues,
} from 'react-hook-form' } 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 * Props produced by {@link safeNumberFieldProps} for a native
......
...@@ -650,3 +650,49 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -650,3 +650,49 @@ For commercial licensing, please contact support@quantumnous.com
animation: none !important; 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;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment