diff --git a/controller/group.go b/controller/group.go index d1de914..cdc783e 100644 --- a/controller/group.go +++ b/controller/group.go @@ -38,11 +38,8 @@ func GetUserGroups(c *gin.Context) { } } } - if _, ok := userUsableGroups["auto"]; ok { - usableGroups["auto"] = map[string]interface{}{ - "ratio": "自动", - "desc": setting.GetUsableGroupDescription("auto"), - } + for _, route := range service.GetUserAutoRoutes(userGroup, true) { + addAutoRouteUsableGroup(usableGroups, route) } c.JSON(http.StatusOK, gin.H{ "success": true, @@ -50,3 +47,15 @@ func GetUserGroups(c *gin.Context) { "data": usableGroups, }) } + +func addAutoRouteUsableGroup(usableGroups map[string]map[string]interface{}, route setting.AutoGroupRoute) { + if _, exists := usableGroups[route.Key]; exists { + return + } + usableGroups[route.Key] = map[string]interface{}{ + "ratio": "自动", + "desc": route.Name, + "auto": true, + "groups": route.Groups, + } +} diff --git a/controller/group_test.go b/controller/group_test.go new file mode 100644 index 0000000..d343371 --- /dev/null +++ b/controller/group_test.go @@ -0,0 +1,28 @@ +package controller + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/setting" + "github.com/stretchr/testify/require" +) + +func TestAddAutoRouteUsableGroupDoesNotOverwriteRatioGroup(t *testing.T) { + usableGroups := map[string]map[string]interface{}{ + "auto:fast": { + "ratio": 0.5, + "desc": "real ratio group", + }, + } + + addAutoRouteUsableGroup(usableGroups, setting.AutoGroupRoute{ + Key: "auto:fast", + Name: "Fast route", + Groups: []string{"vip"}, + }) + + require.Equal(t, map[string]interface{}{ + "ratio": 0.5, + "desc": "real ratio group", + }, usableGroups["auto:fast"]) +} diff --git a/controller/misc.go b/controller/misc.go index 35f80fa..53d7eee 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -92,6 +92,7 @@ func GetStatus(c *gin.Context) { "password_login_enabled": common.PasswordLoginEnabled, "password_register_enabled": common.PasswordRegisterEnabled, "default_use_auto_group": setting.DefaultUseAutoGroup, + "default_auto_route": setting.GetDefaultAutoRouteKey(), "log_audit_enabled": common.LogRequestContentEnabled || common.LogResponseContentEnabled, "log_request_content_enabled": common.LogRequestContentEnabled, "log_response_content_enabled": common.LogResponseContentEnabled, diff --git a/controller/model.go b/controller/model.go index 2d79c95..f709b98 100644 --- a/controller/model.go +++ b/controller/model.go @@ -18,6 +18,7 @@ import ( relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/helper" "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/setting" "github.com/MAX-API-Next/MAX-API/setting/operation_setting" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" @@ -178,7 +179,7 @@ type modelListGroups struct { func getModelListGroups(c *gin.Context) (modelListGroups, error) { tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - if userGroup == "" && (tokenGroup == "" || tokenGroup == "auto") { + if userGroup == "" && (tokenGroup == "" || setting.IsAutoRouteKey(tokenGroup)) { var err error userGroup, err = model.GetUserGroup(c.GetInt("id"), false) if err != nil { @@ -196,11 +197,11 @@ func getContextModelListGroups(c *gin.Context) modelListGroups { } func buildModelListGroups(userGroup string, tokenGroup string) modelListGroups { - if tokenGroup == "auto" { + if setting.IsAutoRouteKey(tokenGroup) { return modelListGroups{ userGroup: userGroup, tokenGroup: tokenGroup, - ownerGroups: service.GetUserAutoGroup(userGroup), + ownerGroups: service.GetUserAutoGroupByRoute(userGroup, tokenGroup), } } @@ -269,7 +270,7 @@ func ListModels(c *gin.Context, modelType int) { } } else { var models []string - if groups.tokenGroup == "auto" { + if setting.IsAutoRouteKey(groups.tokenGroup) { for _, autoGroup := range ownerGroups { groupModels := model.GetGroupEnabledModels(autoGroup) for _, g := range groupModels { diff --git a/controller/option.go b/controller/option.go index dc38d96..ddc2f58 100644 --- a/controller/option.go +++ b/controller/option.go @@ -233,6 +233,15 @@ func UpdateOption(c *gin.Context) { }) return } + case "AutoGroupRoutes": + _, err = setting.ParseAutoGroupRoutesConfig(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "自动链路配置失败: " + err.Error(), + }) + return + } case "ImageRatio": err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string)) if err != nil { diff --git a/controller/perf_metrics.go b/controller/perf_metrics.go index 974e02e..1873c57 100644 --- a/controller/perf_metrics.go +++ b/controller/perf_metrics.go @@ -5,6 +5,7 @@ import ( "strconv" perfmetrics "github.com/MAX-API-Next/MAX-API/pkg/perf_metrics" + "github.com/MAX-API-Next/MAX-API/setting" "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" "github.com/gin-gonic/gin" @@ -19,7 +20,10 @@ func GetPerfMetricsSummary(c *gin.Context) { } } - activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto") + activeGroups := lo.Keys(ratio_setting.GetGroupRatioCopy()) + for _, route := range setting.GetAutoRoutes() { + activeGroups = append(activeGroups, route.Key) + } result, err := perfmetrics.QuerySummaryAll(hours, activeGroups) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -77,6 +81,6 @@ func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupRes activeRatios := ratio_setting.GetGroupRatioCopy() return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool { _, ok := activeRatios[g.Group] - return ok || g.Group == "auto" + return ok || setting.IsAutoRouteKey(g.Group) }) } diff --git a/controller/pricing.go b/controller/pricing.go index 72202fe..f9a7591 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -72,6 +72,7 @@ func GetPricing(c *gin.Context) { "usable_group": usableGroup, "supported_endpoint": model.GetSupportedEndpointMap(), "auto_groups": service.GetUserAutoGroup(group), + "auto_routes": service.GetUserAutoRoutes(group, true), "pricing_version": "a42d372ccf0b5dd13ecf71203521f9d2", }) } diff --git a/controller/token.go b/controller/token.go index 6834f75..ae74304 100644 --- a/controller/token.go +++ b/controller/token.go @@ -4,10 +4,12 @@ import ( "fmt" "net/http" "strconv" + "strings" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/i18n" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/setting/operation_setting" "github.com/gin-gonic/gin" @@ -30,6 +32,22 @@ func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token { return maskedTokens } +func validateAssignableTokenGroup(c *gin.Context, group string) bool { + group = strings.TrimSpace(group) + if group == "" { + return true + } + userGroup := c.GetString("group") + if userGroup == "" { + userGroup = c.GetString("user_group") + } + if service.CanUseTokenGroup(userGroup, group) { + return true + } + common.ApiErrorI18n(c, i18n.MsgTokenGroupNotAssignable, map[string]any{"Group": group}) + return false +} + func GetAllTokens(c *gin.Context) { userId := c.GetInt("id") pageInfo := common.GetPageQuery(c) @@ -157,6 +175,10 @@ func AddToken(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return } + token.Group = strings.TrimSpace(token.Group) + if !validateAssignableTokenGroup(c, token.Group) { + return + } // 非无限额度时,检查额度值是否超出有效范围 if !token.UnlimitedQuota { if token.RemainQuota < 0 { @@ -271,6 +293,10 @@ func UpdateToken(c *gin.Context) { if statusOnly != "" { cleanToken.Status = token.Status } else { + token.Group = strings.TrimSpace(token.Group) + if !validateAssignableTokenGroup(c, token.Group) { + return + } // If you add more fields, please also update token.Update() cleanToken.Name = token.Name cleanToken.ExpiredTime = token.ExpiredTime diff --git a/controller/token_test.go b/controller/token_test.go index 70a85e4..13dedbd 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -13,7 +13,9 @@ import ( "testing" "github.com/MAX-API-Next/MAX-API/common" + appi18n "github.com/MAX-API-Next/MAX-API/i18n" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/setting" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "gorm.io/driver/mysql" @@ -114,6 +116,51 @@ func setupTokenControllerTestDB(t *testing.T) *gorm.DB { return db } +func setupHiddenAutoRouteForTokenTest(t *testing.T) { + t.Helper() + + userGroupsSnapshot := setting.GetUserUsableGroupsCopy() + userGroupsBytes, err := common.Marshal(userGroupsSnapshot) + if err != nil { + t.Fatalf("failed to marshal user group snapshot: %v", err) + } + autoRoutesSnapshot := setting.AutoGroupRoutes2JsonString() + t.Cleanup(func() { + if err := setting.UpdateUserUsableGroupsByJSONString(string(userGroupsBytes)); err != nil { + t.Fatalf("failed to restore user groups: %v", err) + } + if err := setting.UpdateAutoGroupRoutesByJsonString(autoRoutesSnapshot); err != nil { + t.Fatalf("failed to restore auto routes: %v", err) + } + }) + + if err := setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`); err != nil { + t.Fatalf("failed to set user usable groups: %v", err) + } + if err := setting.UpdateAutoGroupRoutesByJsonString(`{ + "version": 1, + "default_route": "auto", + "routes": [ + { + "key": "auto", + "name": "Auto", + "enabled": true, + "user_selectable": true, + "groups": ["default"] + }, + { + "key": "auto:internal", + "name": "Internal", + "enabled": true, + "user_selectable": false, + "groups": ["vip"] + } + ] + }`); err != nil { + t.Fatalf("failed to set auto routes: %v", err) + } +} + func openTokenControllerExternalDB(t *testing.T, dialect string, dsn string) (*gorm.DB, *bool) { t.Helper() @@ -206,6 +253,11 @@ func newAuthenticatedContext(t *testing.T, method string, target string, body an return ctx, recorder } +func setAuthenticatedUserGroup(ctx *gin.Context, group string) { + ctx.Set("group", group) + ctx.Set("user_group", group) +} + func decodeAPIResponse(t *testing.T, recorder *httptest.ResponseRecorder) tokenAPIResponse { t.Helper() @@ -470,6 +522,49 @@ func TestGetTokenMasksKeyInResponse(t *testing.T) { } } +func TestAddTokenRejectsNonSelectableAutoRoute(t *testing.T) { + db := setupTokenControllerTestDB(t) + setupHiddenAutoRouteForTokenTest(t) + if err := appi18n.Init(); err != nil { + t.Fatalf("failed to initialize i18n: %v", err) + } + + body := map[string]any{ + "name": "hidden-route-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "group": "auto:internal", + "cross_group_retry": true, + } + + ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + ctx.Request.Header.Set("Accept-Language", "zh-CN") + AddToken(ctx) + + response := decodeAPIResponse(t, recorder) + if response.Success { + t.Fatalf("expected hidden auto route token creation to fail") + } + if !strings.Contains(response.Message, "auto:internal") { + t.Fatalf("expected error message to mention hidden route, got %q", response.Message) + } + if !strings.Contains(response.Message, "无权访问") { + t.Fatalf("expected localized denial message, got %q", response.Message) + } + + var count int64 + if err := db.Model(&model.Token{}).Count(&count).Error; err != nil { + t.Fatalf("failed to count tokens: %v", err) + } + if count != 0 { + t.Fatalf("expected no token to be created, got %d", count) + } +} + func TestUpdateTokenMasksKeyInResponse(t *testing.T) { db := setupTokenControllerTestDB(t) token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678") @@ -506,6 +601,51 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) { } } +func TestUpdateTokenRejectsNonSelectableAutoRoute(t *testing.T) { + db := setupTokenControllerTestDB(t) + setupHiddenAutoRouteForTokenTest(t) + if err := appi18n.Init(); err != nil { + t.Fatalf("failed to initialize i18n: %v", err) + } + token := seedToken(t, db, 1, "editable-token", "reject1234route5678") + + body := map[string]any{ + "id": token.Id, + "name": "updated-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "group": "auto:internal", + "cross_group_retry": true, + } + + ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + ctx.Request.Header.Set("Accept-Language", "zh-CN") + UpdateToken(ctx) + + response := decodeAPIResponse(t, recorder) + if response.Success { + t.Fatalf("expected hidden auto route token update to fail") + } + if !strings.Contains(response.Message, "auto:internal") { + t.Fatalf("expected error message to mention hidden route, got %q", response.Message) + } + if !strings.Contains(response.Message, "无权访问") { + t.Fatalf("expected localized denial message, got %q", response.Message) + } + + var stored model.Token + if err := db.First(&stored, token.Id).Error; err != nil { + t.Fatalf("failed to reload token: %v", err) + } + if stored.Group != "default" { + t.Fatalf("expected token group to remain default, got %q", stored.Group) + } +} + func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) { db := setupTokenControllerTestDB(t) token := seedToken(t, db, 1, "owned-token", "owner1234token5678") diff --git a/controller/user.go b/controller/user.go index de6d310..a996d7f 100644 --- a/controller/user.go +++ b/controller/user.go @@ -252,7 +252,7 @@ func Register(c *gin.Context) { ModelLimitsEnabled: false, } if setting.DefaultUseAutoGroup { - token.Group = "auto" + token.Group = setting.GetDefaultAutoRouteKey() } if err := token.Insert(); err != nil { common.ApiErrorI18n(c, i18n.MsgCreateDefaultTokenErr) diff --git a/i18n/keys.go b/i18n/keys.go index 5630cf0..5d4f4d1 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -47,6 +47,7 @@ const ( MsgTokenQuotaExceedMax = "token.quota_exceed_max" MsgTokenGenerateFailed = "token.generate_failed" MsgTokenGetInfoFailed = "token.get_info_failed" + MsgTokenGroupNotAssignable = "token.group_not_assignable" MsgTokenExpiredCannotEnable = "token.expired_cannot_enable" MsgTokenExhaustedCannotEable = "token.exhausted_cannot_enable" MsgTokenInvalid = "token.invalid" diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index 2455655..ece1229 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -39,6 +39,7 @@ token.quota_negative: "Quota value cannot be negative" token.quota_exceed_max: "Quota value exceeds valid range, maximum is {{.Max}}" token.generate_failed: "Failed to generate token" token.get_info_failed: "Failed to get token info, please try again later" +token.group_not_assignable: "No permission to assign group {{.Group}}" token.expired_cannot_enable: "Token has expired and cannot be enabled. Please modify the expiration time or set it to never expire" token.exhausted_cannot_enable: "Token quota is exhausted and cannot be enabled. Please modify the remaining quota or set it to unlimited" token.invalid: "Invalid token" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 0f11c65..1b7a27e 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -40,6 +40,7 @@ token.quota_negative: "额度值不能为负数" token.quota_exceed_max: "额度值超出有效范围,最大值为 {{.Max}}" token.generate_failed: "生成令牌失败" token.get_info_failed: "获取令牌信息失败,请稍后重试" +token.group_not_assignable: "无权访问 {{.Group}} 分组" token.expired_cannot_enable: "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期" token.exhausted_cannot_enable: "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度" token.invalid: "无效的令牌" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index aa0cf0a..466b731 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -40,6 +40,7 @@ token.quota_negative: "額度值不能為負數" token.quota_exceed_max: "額度值超出有效範圍,最大值為 {{.Max}}" token.generate_failed: "生成令牌失敗" token.get_info_failed: "獲取令牌資訊失敗,請稍後重試" +token.group_not_assignable: "無權分配 {{.Group}} 分組" token.expired_cannot_enable: "令牌已過期,無法啟用,請先修改令牌過期時間,或者設定為永不過期" token.exhausted_cannot_enable: "令牌可用額度已用盡,無法啟用,請先修改令牌剩餘額度,或者設定為無限額度" token.invalid: "無效的令牌" diff --git a/main.go b/main.go index 6ce4b3b..7d4b139 100644 --- a/main.go +++ b/main.go @@ -256,6 +256,11 @@ func runWithTimeout(timeout time.Duration, fn func()) bool { done := make(chan struct{}) go func() { defer close(done) + defer func() { + if r := recover(); r != nil { + common.SysError(fmt.Sprintf("runWithTimeout: recovered panic: %v", r)) + } + }() fn() }() diff --git a/main_test.go b/main_test.go index 8e018a2..2477324 100644 --- a/main_test.go +++ b/main_test.go @@ -24,6 +24,12 @@ func TestRunWithTimeoutTimesOut(t *testing.T) { assert.Less(t, time.Since(start), 250*time.Millisecond) } +func TestRunWithTimeoutRecoversPanic(t *testing.T) { + assert.True(t, runWithTimeout(time.Second, func() { + panic("quota save failed") + })) +} + func TestShutdownHTTPServerClosesActiveHandlersAfterTimeout(t *testing.T) { handlerStarted := make(chan struct{}) handlerDone := make(chan struct{}) diff --git a/middleware/auth.go b/middleware/auth.go index cbde420..b3699c2 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -469,16 +469,14 @@ func TokenAuth() func(c *gin.Context) { tokenGroup := token.Group if tokenGroup != "" { // check common.UserUsableGroups[userGroup] - if _, ok := service.GetUserUsableGroups(userGroup)[tokenGroup]; !ok { + if !service.CanUseTokenGroupRuntime(userGroup, tokenGroup) { abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("无权访问 %s 分组", tokenGroup)) return } // check group in common.GroupRatio - if !ratio_setting.ContainsGroupRatio(tokenGroup) { - if tokenGroup != "auto" { - abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) - return - } + if !service.IsAutoRouteKey(tokenGroup) && !ratio_setting.ContainsGroupRatio(tokenGroup) { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) + return } userGroup = tokenGroup } diff --git a/middleware/distributor.go b/middleware/distributor.go index fde3f64..54063c1 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -92,7 +92,8 @@ func Distribute() func(c *gin.Context) { return } if playgroundRequest.Group != "" { - if !service.GroupInUserUsableGroups(usingGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup { + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + if !service.CanUseTokenGroup(userGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup { abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorGroupAccessDenied)) return } @@ -106,9 +107,9 @@ func Distribute() func(c *gin.Context) { preferred, err := model.CacheGetChannel(preferredChannelID) if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled && channelSupportsRequestPath(preferred, c.Request.URL.Path) { - if usingGroup == "auto" { + if service.IsAutoRouteKey(usingGroup) { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - autoGroups := service.GetUserAutoGroup(userGroup) + autoGroups := service.GetUserAutoGroupByRoute(userGroup, usingGroup) for _, g := range autoGroups { if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { selectGroup = g @@ -141,8 +142,8 @@ func Distribute() func(c *gin.Context) { }) if err != nil { showGroup := usingGroup - if usingGroup == "auto" { - showGroup = fmt.Sprintf("auto(%s)", selectGroup) + if service.IsAutoRouteKey(usingGroup) { + showGroup = fmt.Sprintf("%s(%s)", usingGroup, selectGroup) } message := i18n.T(c, i18n.MsgDistributorGetChannelFailed, map[string]any{"Group": showGroup, "Model": modelRequest.Model, "Error": err.Error()}) // 如果错误,但是渠道不为空,说明是数据库一致性问题 diff --git a/model/option.go b/model/option.go index b81291e..987c3f8 100644 --- a/model/option.go +++ b/model/option.go @@ -123,6 +123,7 @@ func InitOptionMap() { common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() + common.OptionMap["AutoGroupRoutes"] = setting.AutoGroupRoutes2JsonString() common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString() common.OptionMap["GitHubClientId"] = "" @@ -210,6 +211,10 @@ func SyncOptions(frequency int) { } func UpdateOption(key string, value string) error { + if err := validateOptionUpdate(key, value); err != nil { + return err + } + // Save to database first option := Option{ Key: key, @@ -234,6 +239,11 @@ func UpdateOptionsBulk(values map[string]string) error { if len(values) == 0 { return nil } + for k, v := range values { + if err := validateOptionUpdate(k, v); err != nil { + return err + } + } err := DB.Transaction(func(tx *gorm.DB) error { for k, v := range values { option := Option{Key: k} @@ -258,14 +268,56 @@ func UpdateOptionsBulk(values map[string]string) error { return nil } +func validateOptionUpdate(key string, value string) error { + switch key { + case "Chats": + return validateJSONOption[[]map[string]string](value) + case "AutoGroups": + return setting.ValidateAutoGroupsJsonString(value) + case "AutoGroupRoutes": + _, err := setting.ParseAutoGroupRoutesConfig(value) + return err + case "TopupGroupRatio": + return validateJSONOption[map[string]float64](value) + case "ModelRequestRateLimitGroup": + return setting.CheckModelRequestRateLimitGroup(value) + case "ModelRatio", "ModelPrice", "CacheRatio", "CreateCacheRatio", "CompletionRatio", "ImageRatio", "AudioRatio", "AudioCompletionRatio": + return validateJSONOption[map[string]float64](value) + case "GroupRatio", "group_ratio_setting.group_ratio": + return ratio_setting.CheckGroupRatio(value) + case "GroupGroupRatio": + return validateJSONOption[map[string]map[string]float64](value) + case "UserUsableGroups": + return validateJSONOption[map[string]string](value) + case "AutomaticDisableStatusCodes", "AutomaticRetryStatusCodes": + _, err := operation_setting.ParseHTTPStatusCodeRanges(value) + return err + case "PayMethods": + return validateJSONOption[[]map[string]string](value) + case "task_billing_setting.rate_cards": + return task_billing_setting.ValidateRateCardsJSON(value) + default: + return nil + } +} + +func validateJSONOption[T any](value string) error { + var target T + return common.UnmarshalJsonStr(value, &target) +} + func updateOptionMap(key string, value string) (err error) { + if err := validateOptionUpdate(key, value); err != nil { + return err + } + common.OptionMapRWMutex.Lock() defer common.OptionMapRWMutex.Unlock() common.OptionMap[key] = value // 检查是否是模型配置 - 使用更规范的方式处理 - if handleConfigUpdate(key, value) { - return nil // 已由配置系统处理 + if handled, err := handleConfigUpdate(key, value); handled { + return err // 已由配置系统处理 } // 处理传统配置项... @@ -409,6 +461,16 @@ func updateOptionMap(key string, value string) (err error) { err = setting.UpdateChatsByJsonString(value) case "AutoGroups": err = setting.UpdateAutoGroupsByJsonString(value) + if err == nil { + common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() + common.OptionMap["AutoGroupRoutes"] = setting.AutoGroupRoutes2JsonString() + } + case "AutoGroupRoutes": + err = setting.UpdateAutoGroupRoutesByJsonString(value) + if err == nil { + common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() + common.OptionMap["AutoGroupRoutes"] = setting.AutoGroupRoutes2JsonString() + } case "CustomCallbackAddress": operation_setting.CustomCallbackAddress = value case "EpayId": @@ -594,10 +656,10 @@ func updateOptionMap(key string, value string) (err error) { } // handleConfigUpdate 处理分层配置更新,返回是否已处理 -func handleConfigUpdate(key, value string) bool { +func handleConfigUpdate(key, value string) (bool, error) { parts := strings.SplitN(key, ".", 2) if len(parts) != 2 { - return false // 不是分层配置 + return false, nil // 不是分层配置 } configName := parts[0] @@ -606,7 +668,7 @@ func handleConfigUpdate(key, value string) bool { // 获取配置对象 cfg := config.GlobalConfig.Get(configName) if cfg == nil { - return false // 未注册的配置 + return false, nil // 未注册的配置 } // 更新配置 @@ -632,5 +694,5 @@ func handleConfigUpdate(key, value string) bool { system_setting.UpdateAndSyncTheme() } - return true // 已处理 + return true, nil // 已处理 } diff --git a/model/option_test.go b/model/option_test.go new file mode 100644 index 0000000..281b774 --- /dev/null +++ b/model/option_test.go @@ -0,0 +1,145 @@ +package model + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" + "github.com/stretchr/testify/require" +) + +func setupOptionMapTestState(t *testing.T) { + t.Helper() + + originalGroupRatio := ratio_setting.GroupRatio2JSONString() + common.OptionMapRWMutex.Lock() + originalOptionMap := common.OptionMap + common.OptionMap = map[string]string{} + common.OptionMapRWMutex.Unlock() + + t.Cleanup(func() { + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatio)) + common.OptionMapRWMutex.Lock() + common.OptionMap = originalOptionMap + common.OptionMapRWMutex.Unlock() + }) +} + +func optionMapContainsForTest(key string) bool { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + _, ok := common.OptionMap[key] + return ok +} + +func deleteOptionsForTest(t *testing.T, keys ...string) { + t.Helper() + require.NoError(t, DB.Where(commonKeyCol+" IN ?", keys).Delete(&Option{}).Error) +} + +func optionExistsForTest(t *testing.T, key string) bool { + t.Helper() + var count int64 + require.NoError(t, DB.Model(&Option{}).Where(commonKeyCol+" = ?", key).Count(&count).Error) + return count > 0 +} + +func TestUpdateOptionRejectsAutoRouteGroupRatioNamesBeforePersistence(t *testing.T) { + setupOptionMapTestState(t) + + err := UpdateOption("GroupRatio", `{"auto":1}`) + require.Error(t, err) + require.Contains(t, err.Error(), "auto route namespace") + require.False(t, optionMapContainsForTest("GroupRatio")) + + err = UpdateOptionsBulk(map[string]string{ + "group_ratio_setting.group_ratio": `{"auto:fast":1}`, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "auto route namespace") + require.False(t, optionMapContainsForTest("group_ratio_setting.group_ratio")) +} + +func TestUpdateOptionMapRejectsAutoRouteGroupRatioNames(t *testing.T) { + setupOptionMapTestState(t) + + err := updateOptionMap("GroupRatio", `{"auto":1}`) + require.Error(t, err) + require.Contains(t, err.Error(), "auto route namespace") + require.NotContains(t, ratio_setting.GetGroupRatioCopy(), "auto") + + err = updateOptionMap("group_ratio_setting.group_ratio", `{"auto:fast":1}`) + require.Error(t, err) + require.Contains(t, err.Error(), "auto route namespace") + require.NotContains(t, ratio_setting.GetGroupRatioCopy(), "auto:fast") + + require.NoError(t, updateOptionMap("GroupRatio", `{"default":1,"vip":0.5}`)) + require.Equal(t, 0.5, ratio_setting.GetGroupRatio("vip")) +} + +func TestValidateOptionUpdateRejectsRuntimeConfigParseErrors(t *testing.T) { + tests := []struct { + name string + key string + value string + }{ + { + name: "auto groups reject nested auto routes", + key: "AutoGroups", + value: `["default","auto:fast"]`, + }, + { + name: "auto route config rejects disabled default", + key: "AutoGroupRoutes", + value: `{ + "version":1, + "default_route":"auto", + "routes":[{"key":"auto","enabled":false,"user_selectable":true,"groups":["default"]}] + }`, + }, + { + name: "model ratio rejects malformed json", + key: "ModelRatio", + value: `{`, + }, + { + name: "pay methods reject malformed json", + key: "PayMethods", + value: `{`, + }, + { + name: "status code ranges reject out of bounds", + key: "AutomaticRetryStatusCodes", + value: `999`, + }, + { + name: "request rate limits reject invalid limits", + key: "ModelRequestRateLimitGroup", + value: `{"vip":[-1,1]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Error(t, validateOptionUpdate(tt.key, tt.value)) + }) + } +} + +func TestUpdateOptionsBulkRejectsRuntimeConfigErrorsBeforePersistence(t *testing.T) { + setupOptionMapTestState(t) + deleteOptionsForTest(t, "SystemName", "ModelRatio") + t.Cleanup(func() { + deleteOptionsForTest(t, "SystemName", "ModelRatio") + }) + + err := UpdateOptionsBulk(map[string]string{ + "SystemName": "should-not-persist", + "ModelRatio": `{`, + }) + require.Error(t, err) + require.False(t, optionExistsForTest(t, "SystemName")) + require.False(t, optionExistsForTest(t, "ModelRatio")) + require.False(t, optionMapContainsForTest("SystemName")) + require.False(t, optionMapContainsForTest("ModelRatio")) +} diff --git a/service/channel_select.go b/service/channel_select.go index f818beb..e53f850 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -49,8 +49,8 @@ func (p *RetryParam) ResetRetryNextTry() { // CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements. // 尝试获取一个满足要求的随机渠道。 // -// For "auto" tokenGroup with cross-group Retry enabled: -// 对于启用了跨分组重试的 "auto" tokenGroup: +// For auto route token groups ("auto" or "auto:") with cross-group Retry enabled: +// 对于启用了跨分组重试的自动链路 tokenGroup("auto" 或 "auto:"): // // - Each group will exhaust all its priorities before moving to the next group. // 每个分组会用完所有优先级后才会切换到下一个分组。 @@ -87,11 +87,11 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, selectGroup := param.TokenGroup userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup) - if param.TokenGroup == "auto" { - if len(setting.GetAutoGroups()) == 0 { - return nil, selectGroup, errors.New("auto groups is not enabled") + if setting.IsAutoRouteKey(param.TokenGroup) { + autoGroups := GetUserAutoGroupByRoute(userGroup, param.TokenGroup) + if len(autoGroups) == 0 { + return nil, selectGroup, errors.New("auto route groups is not enabled") } - autoGroups := GetUserAutoGroup(userGroup) // startGroupIndex: the group index to start searching from // startGroupIndex: 开始搜索的分组索引 diff --git a/service/group.go b/service/group.go index 8ba7014..3c341df 100644 --- a/service/group.go +++ b/service/group.go @@ -37,15 +37,34 @@ func GetUserUsableGroups(userGroup string) map[string]string { } func GroupInUserUsableGroups(userGroup, groupName string) bool { + return CanUseTokenGroup(userGroup, groupName) +} + +func IsAutoRouteKey(groupName string) bool { + return setting.IsAutoRouteKey(groupName) +} + +func CanUseTokenGroup(userGroup, groupName string) bool { + return canUseTokenGroup(userGroup, groupName, true) +} + +func CanUseTokenGroupRuntime(userGroup, groupName string) bool { + return canUseTokenGroup(userGroup, groupName, false) +} + +func canUseTokenGroup(userGroup, groupName string, requireUserSelectable bool) bool { + if setting.IsAutoRouteKey(groupName) { + _, ok := GetUserAutoRoute(userGroup, groupName, requireUserSelectable) + return ok + } _, ok := GetUserUsableGroups(userGroup)[groupName] return ok } -// GetUserAutoGroup 根据用户分组获取自动分组设置 -func GetUserAutoGroup(userGroup string) []string { +func filterUserAutoRouteGroups(userGroup string, routeGroups []string) []string { groups := GetUserUsableGroups(userGroup) autoGroups := make([]string, 0) - for _, group := range setting.GetAutoGroups() { + for _, group := range routeGroups { if _, ok := groups[group]; ok { autoGroups = append(autoGroups, group) } @@ -53,6 +72,49 @@ func GetUserAutoGroup(userGroup string) []string { return autoGroups } +func GetUserAutoRoute(userGroup, routeKey string, requireUserSelectable bool) (setting.AutoGroupRoute, bool) { + if routeKey == "" { + routeKey = setting.GetDefaultAutoRouteKey() + } + route, ok := setting.GetAutoRoute(routeKey) + if !ok || !route.Enabled { + return setting.AutoGroupRoute{}, false + } + if requireUserSelectable && !route.UserSelectable { + return setting.AutoGroupRoute{}, false + } + route.Groups = filterUserAutoRouteGroups(userGroup, route.Groups) + if len(route.Groups) == 0 { + return setting.AutoGroupRoute{}, false + } + return route, true +} + +func GetUserAutoRoutes(userGroup string, requireUserSelectable bool) []setting.AutoGroupRoute { + routes := setting.GetAutoRoutes() + userRoutes := make([]setting.AutoGroupRoute, 0, len(routes)) + for _, route := range routes { + userRoute, ok := GetUserAutoRoute(userGroup, route.Key, requireUserSelectable) + if ok { + userRoutes = append(userRoutes, userRoute) + } + } + return userRoutes +} + +// GetUserAutoGroup 根据用户分组获取默认自动分组设置 +func GetUserAutoGroup(userGroup string) []string { + return GetUserAutoGroupByRoute(userGroup, setting.GetDefaultAutoRouteKey()) +} + +func GetUserAutoGroupByRoute(userGroup, routeKey string) []string { + route, ok := GetUserAutoRoute(userGroup, routeKey, false) + if !ok { + return []string{} + } + return route.Groups +} + // GetUserGroupRatio 获取用户使用某个分组的倍率 // userGroup 用户分组 // group 需要获取倍率的分组 diff --git a/service/group_test.go b/service/group_test.go new file mode 100644 index 0000000..b9215f5 --- /dev/null +++ b/service/group_test.go @@ -0,0 +1,51 @@ +package service + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/setting" + "github.com/stretchr/testify/require" +) + +func TestHiddenAutoRouteRuntimeUsableButNotUserSelectable(t *testing.T) { + userGroupsSnapshot := setting.GetUserUsableGroupsCopy() + userGroupsBytes, err := common.Marshal(userGroupsSnapshot) + require.NoError(t, err) + autoRoutesSnapshot := setting.AutoGroupRoutes2JsonString() + defer func() { + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(string(userGroupsBytes))) + require.NoError(t, setting.UpdateAutoGroupRoutesByJsonString(autoRoutesSnapshot)) + }() + + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`)) + require.NoError(t, setting.UpdateAutoGroupRoutesByJsonString(`{ + "version": 1, + "default_route": "auto", + "routes": [ + { + "key": "auto", + "name": "Auto", + "enabled": true, + "user_selectable": true, + "groups": ["default"] + }, + { + "key": "auto:internal", + "name": "Internal", + "enabled": true, + "user_selectable": false, + "groups": ["vip", "svip"] + } + ] + }`)) + + _, selectable := GetUserAutoRoute("default", "auto:internal", true) + require.False(t, selectable) + require.False(t, CanUseTokenGroup("default", "auto:internal")) + + route, runtimeUsable := GetUserAutoRoute("default", "auto:internal", false) + require.True(t, runtimeUsable) + require.True(t, CanUseTokenGroupRuntime("default", "auto:internal")) + require.Equal(t, []string{"vip"}, route.Groups) +} diff --git a/setting/auto_group.go b/setting/auto_group.go index 4f0b5eb..9d64f78 100644 --- a/setting/auto_group.go +++ b/setting/auto_group.go @@ -1,17 +1,93 @@ package setting import ( + "errors" + "fmt" + "regexp" + "slices" + "strings" + "sync" + "github.com/MAX-API-Next/MAX-API/common" ) +const ( + DefaultAutoRouteKey = "auto" + AutoRoutePrefix = "auto:" +) + +var autoRouteKeyPattern = regexp.MustCompile(`^auto(?::[A-Za-z0-9][A-Za-z0-9._-]{0,58})?$`) + +type AutoGroupRoute struct { + Key string `json:"key"` + Name string `json:"name,omitempty"` + Enabled bool `json:"enabled"` + UserSelectable bool `json:"user_selectable"` + Groups []string `json:"groups"` +} + +func (route *AutoGroupRoute) UnmarshalJSON(data []byte) error { + var raw struct { + Key string `json:"key"` + Name string `json:"name,omitempty"` + Enabled *bool `json:"enabled"` + UserSelectable *bool `json:"user_selectable"` + Groups []string `json:"groups"` + } + if err := common.Unmarshal(data, &raw); err != nil { + return err + } + route.Key = raw.Key + route.Name = raw.Name + route.Enabled = true + if raw.Enabled != nil { + route.Enabled = *raw.Enabled + } + route.UserSelectable = true + if raw.UserSelectable != nil { + route.UserSelectable = *raw.UserSelectable + } + route.Groups = raw.Groups + return nil +} + +type AutoGroupRoutesConfig struct { + Version int `json:"version"` + DefaultRoute string `json:"default_route"` + Routes []AutoGroupRoute `json:"routes"` +} + var autoGroups = []string{ "default", } var DefaultUseAutoGroup = false +var ( + autoGroupMu sync.RWMutex + autoGroupRoutesExplicit bool + autoGroupRoutesConfig = AutoGroupRoutesConfig{ + Version: 1, + DefaultRoute: DefaultAutoRouteKey, + Routes: []AutoGroupRoute{ + { + Key: DefaultAutoRouteKey, + Name: "自动", + Enabled: true, + UserSelectable: true, + Groups: []string{"default"}, + }, + }, + } +) + +func IsAutoRouteKey(group string) bool { + group = strings.TrimSpace(group) + return group == DefaultAutoRouteKey || strings.HasPrefix(group, AutoRoutePrefix) +} + func ContainsAutoGroup(group string) bool { - for _, autoGroup := range autoGroups { + for _, autoGroup := range GetAutoGroups() { if autoGroup == group { return true } @@ -19,13 +95,30 @@ func ContainsAutoGroup(group string) bool { return false } +func ContainsAutoRouteKey(routeKey string) bool { + _, ok := GetAutoRoute(routeKey) + return ok +} + func UpdateAutoGroupsByJsonString(jsonString string) error { - autoGroups = make([]string, 0) - return common.Unmarshal([]byte(jsonString), &autoGroups) + groups, err := parseAutoGroupsJSON(jsonString) + if err != nil { + return err + } + autoGroupMu.Lock() + defer autoGroupMu.Unlock() + if !autoGroupRoutesExplicit { + autoGroups = groups + autoGroupRoutesConfig = configFromLegacyGroupsLocked(groups) + } + return nil } func AutoGroups2JsonString() string { - jsonBytes, err := common.Marshal(autoGroups) + autoGroupMu.RLock() + groups := slices.Clone(autoGroups) + autoGroupMu.RUnlock() + jsonBytes, err := common.Marshal(groups) if err != nil { return "[]" } @@ -33,5 +126,246 @@ func AutoGroups2JsonString() string { } func GetAutoGroups() []string { - return autoGroups + autoGroupMu.RLock() + defer autoGroupMu.RUnlock() + return slices.Clone(autoGroups) +} + +func AutoGroupRoutes2JsonString() string { + config := GetAutoGroupRoutesConfig() + jsonBytes, err := common.Marshal(config) + if err != nil { + return "{}" + } + return string(jsonBytes) +} + +func UpdateAutoGroupRoutesByJsonString(jsonString string) error { + config, err := ParseAutoGroupRoutesConfig(jsonString) + if err != nil { + return err + } + autoGroupMu.Lock() + defer autoGroupMu.Unlock() + autoGroupRoutesConfig = config + autoGroupRoutesExplicit = true + autoGroups = defaultRouteGroupsLocked(config) + return nil +} + +func GetAutoRouteGroups(routeKey string) ([]string, bool) { + route, ok := GetAutoRoute(routeKey) + if !ok { + return nil, false + } + return slices.Clone(route.Groups), true +} + +func ParseAutoGroupRoutesConfig(jsonString string) (AutoGroupRoutesConfig, error) { + trimmed := strings.TrimSpace(jsonString) + if trimmed == "" { + return AutoGroupRoutesConfig{}, errors.New("auto group routes config is empty") + } + if strings.HasPrefix(trimmed, "[") { + groups, err := parseAutoGroupsJSON(trimmed) + if err != nil { + return AutoGroupRoutesConfig{}, err + } + return configFromLegacyGroups(groups), nil + } + var config AutoGroupRoutesConfig + if err := common.Unmarshal([]byte(trimmed), &config); err != nil { + return AutoGroupRoutesConfig{}, err + } + return normalizeAutoGroupRoutesConfig(config) +} + +func GetAutoGroupRoutesConfig() AutoGroupRoutesConfig { + autoGroupMu.RLock() + defer autoGroupMu.RUnlock() + return cloneAutoGroupRoutesConfig(autoGroupRoutesConfig) +} + +func GetAutoRoutes() []AutoGroupRoute { + config := GetAutoGroupRoutesConfig() + return config.Routes +} + +func GetAutoRoute(routeKey string) (AutoGroupRoute, bool) { + routeKey = strings.TrimSpace(routeKey) + autoGroupMu.RLock() + defer autoGroupMu.RUnlock() + for _, route := range autoGroupRoutesConfig.Routes { + if route.Key == routeKey { + return cloneAutoGroupRoute(route), true + } + } + return AutoGroupRoute{}, false +} + +func GetDefaultAutoRouteKey() string { + autoGroupMu.RLock() + defer autoGroupMu.RUnlock() + if autoGroupRoutesConfig.DefaultRoute != "" { + return autoGroupRoutesConfig.DefaultRoute + } + return DefaultAutoRouteKey +} + +func ValidateAutoGroupsJsonString(jsonString string) error { + _, err := parseAutoGroupsJSON(jsonString) + return err +} + +func parseAutoGroupsJSON(jsonString string) ([]string, error) { + var groups []string + if err := common.Unmarshal([]byte(jsonString), &groups); err != nil { + return nil, err + } + return normalizeAutoGroupList(groups) +} + +func configFromLegacyGroups(groups []string) AutoGroupRoutesConfig { + normalized, err := normalizeAutoGroupList(groups) + if err != nil { + normalized = []string{"default"} + } + return configFromLegacyGroupsLocked(normalized) +} + +func configFromLegacyGroupsLocked(groups []string) AutoGroupRoutesConfig { + return AutoGroupRoutesConfig{ + Version: 1, + DefaultRoute: DefaultAutoRouteKey, + Routes: []AutoGroupRoute{ + { + Key: DefaultAutoRouteKey, + Name: "自动", + Enabled: true, + UserSelectable: true, + Groups: slices.Clone(groups), + }, + }, + } +} + +func normalizeAutoGroupRoutesConfig(config AutoGroupRoutesConfig) (AutoGroupRoutesConfig, error) { + if config.Version == 0 { + config.Version = 1 + } + if config.Version != 1 { + return AutoGroupRoutesConfig{}, fmt.Errorf("unsupported auto group routes config version: %d", config.Version) + } + config.DefaultRoute = strings.TrimSpace(config.DefaultRoute) + if config.DefaultRoute == "" { + config.DefaultRoute = DefaultAutoRouteKey + } + if !autoRouteKeyPattern.MatchString(config.DefaultRoute) { + return AutoGroupRoutesConfig{}, fmt.Errorf("invalid default auto route key: %s", config.DefaultRoute) + } + if len(config.Routes) == 0 { + return AutoGroupRoutesConfig{}, errors.New("auto group routes must not be empty") + } + if len(config.Routes) > 32 { + return AutoGroupRoutesConfig{}, errors.New("auto group routes must not exceed 32 entries") + } + + seen := make(map[string]struct{}, len(config.Routes)) + hasDefault := false + defaultEnabled := false + routes := make([]AutoGroupRoute, 0, len(config.Routes)) + for _, route := range config.Routes { + normalized, err := normalizeAutoGroupRoute(route) + if err != nil { + return AutoGroupRoutesConfig{}, err + } + if _, ok := seen[normalized.Key]; ok { + return AutoGroupRoutesConfig{}, fmt.Errorf("duplicate auto route key: %s", normalized.Key) + } + seen[normalized.Key] = struct{}{} + if normalized.Key == config.DefaultRoute { + hasDefault = true + defaultEnabled = normalized.Enabled + } + routes = append(routes, normalized) + } + if !hasDefault { + return AutoGroupRoutesConfig{}, fmt.Errorf("default auto route %s is not defined", config.DefaultRoute) + } + if !defaultEnabled { + return AutoGroupRoutesConfig{}, fmt.Errorf("default auto route %s must be enabled", config.DefaultRoute) + } + config.Routes = routes + return config, nil +} + +func normalizeAutoGroupRoute(route AutoGroupRoute) (AutoGroupRoute, error) { + route.Key = strings.TrimSpace(route.Key) + if !autoRouteKeyPattern.MatchString(route.Key) { + return AutoGroupRoute{}, fmt.Errorf("invalid auto route key: %s", route.Key) + } + route.Name = strings.TrimSpace(route.Name) + if route.Name == "" { + route.Name = route.Key + } + if len([]rune(route.Name)) > 64 { + return AutoGroupRoute{}, fmt.Errorf("auto route %s name must not exceed 64 characters", route.Key) + } + groups, err := normalizeAutoGroupList(route.Groups) + if err != nil { + return AutoGroupRoute{}, fmt.Errorf("auto route %s: %w", route.Key, err) + } + route.Groups = groups + return route, nil +} + +func normalizeAutoGroupList(groups []string) ([]string, error) { + if len(groups) == 0 { + return nil, errors.New("group list must not be empty") + } + if len(groups) > 64 { + return nil, errors.New("group list must not exceed 64 entries") + } + seen := make(map[string]struct{}, len(groups)) + normalized := make([]string, 0, len(groups)) + for _, group := range groups { + group = strings.TrimSpace(group) + if group == "" { + continue + } + if IsAutoRouteKey(group) { + return nil, fmt.Errorf("group list must contain real groups only: %s", group) + } + if _, ok := seen[group]; ok { + continue + } + seen[group] = struct{}{} + normalized = append(normalized, group) + } + if len(normalized) == 0 { + return nil, errors.New("group list must contain at least one real group") + } + return normalized, nil +} + +func defaultRouteGroupsLocked(config AutoGroupRoutesConfig) []string { + for _, route := range config.Routes { + if route.Key == config.DefaultRoute { + return slices.Clone(route.Groups) + } + } + return []string{} +} + +func cloneAutoGroupRoutesConfig(config AutoGroupRoutesConfig) AutoGroupRoutesConfig { + config.Routes = slices.Clone(config.Routes) + for i := range config.Routes { + config.Routes[i] = cloneAutoGroupRoute(config.Routes[i]) + } + return config +} + +func cloneAutoGroupRoute(route AutoGroupRoute) AutoGroupRoute { + route.Groups = slices.Clone(route.Groups) + return route } diff --git a/setting/auto_group_test.go b/setting/auto_group_test.go new file mode 100644 index 0000000..25f49a7 --- /dev/null +++ b/setting/auto_group_test.go @@ -0,0 +1,121 @@ +package setting + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func snapshotAutoGroupState() ([]string, bool, AutoGroupRoutesConfig) { + autoGroupMu.RLock() + defer autoGroupMu.RUnlock() + return autoGroups, autoGroupRoutesExplicit, cloneAutoGroupRoutesConfig(autoGroupRoutesConfig) +} + +func restoreAutoGroupState(groups []string, explicit bool, config AutoGroupRoutesConfig) { + autoGroupMu.Lock() + defer autoGroupMu.Unlock() + autoGroups = groups + autoGroupRoutesExplicit = explicit + autoGroupRoutesConfig = config +} + +func TestParseAutoGroupRoutesConfigAcceptsLegacyAutoGroups(t *testing.T) { + config, err := ParseAutoGroupRoutesConfig(`["default","vip","default"]`) + require.NoError(t, err) + require.Equal(t, DefaultAutoRouteKey, config.DefaultRoute) + require.Len(t, config.Routes, 1) + require.Equal(t, []string{"default", "vip"}, config.Routes[0].Groups) +} + +func TestUpdateAutoGroupRoutesIgnoresStaleLegacyAutoGroups(t *testing.T) { + groups, explicit, config := snapshotAutoGroupState() + defer restoreAutoGroupState(groups, explicit, config) + + err := UpdateAutoGroupRoutesByJsonString(`{ + "version": 1, + "default_route": "auto:fast", + "routes": [ + { + "key": "auto", + "name": "Auto", + "enabled": true, + "user_selectable": true, + "groups": ["default"] + }, + { + "key": "auto:fast", + "name": "Fast", + "enabled": true, + "user_selectable": true, + "groups": ["vip", "svip"] + } + ] + }`) + require.NoError(t, err) + require.Equal(t, []string{"vip", "svip"}, GetAutoGroups()) + require.Equal(t, "auto:fast", GetDefaultAutoRouteKey()) + + err = UpdateAutoGroupsByJsonString(`["stale"]`) + require.NoError(t, err) + require.Equal(t, []string{"vip", "svip"}, GetAutoGroups()) +} + +func TestParseAutoGroupRoutesRejectsNestedAutoRouteAsRealGroup(t *testing.T) { + _, err := ParseAutoGroupRoutesConfig(`{ + "version": 1, + "default_route": "auto", + "routes": [ + { + "key": "auto", + "enabled": true, + "user_selectable": true, + "groups": ["default", "auto:fast"] + } + ] + }`) + require.Error(t, err) +} + +func TestParseAutoGroupRoutesDefaultsMissingRouteFlagsToEnabled(t *testing.T) { + config, err := ParseAutoGroupRoutesConfig(`{ + "version": 1, + "default_route": "auto", + "routes": [ + { + "key": "auto", + "name": "Auto", + "groups": ["default"] + } + ] + }`) + require.NoError(t, err) + require.Len(t, config.Routes, 1) + require.True(t, config.Routes[0].Enabled) + require.True(t, config.Routes[0].UserSelectable) +} + +func TestParseAutoGroupRoutesRejectsDisabledDefaultRoute(t *testing.T) { + _, err := ParseAutoGroupRoutesConfig(`{ + "version": 1, + "default_route": "auto:fast", + "routes": [ + { + "key": "auto", + "name": "Auto", + "enabled": true, + "user_selectable": true, + "groups": ["default"] + }, + { + "key": "auto:fast", + "name": "Fast", + "enabled": false, + "user_selectable": true, + "groups": ["vip"] + } + ] + }`) + require.Error(t, err) + require.Contains(t, err.Error(), "must be enabled") +} diff --git a/setting/ratio_setting/group_ratio.go b/setting/ratio_setting/group_ratio.go index 2fb8eee..cbfdfcc 100644 --- a/setting/ratio_setting/group_ratio.go +++ b/setting/ratio_setting/group_ratio.go @@ -1,8 +1,8 @@ package ratio_setting import ( - "encoding/json" "errors" + "strings" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/setting/config" @@ -15,6 +15,11 @@ var defaultGroupRatio = map[string]float64{ "svip": 1, } +const ( + defaultAutoRouteGroupName = "auto" + autoRouteGroupNamePrefix = "auto:" +) + var groupRatioMap = types.NewRWMap[string, float64]() var defaultGroupGroupRatio = map[string]map[string]float64{ @@ -112,14 +117,22 @@ func UpdateGroupGroupRatioByJSONString(jsonStr string) error { func CheckGroupRatio(jsonStr string) error { checkGroupRatio := make(map[string]float64) - err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio) + err := common.Unmarshal([]byte(jsonStr), &checkGroupRatio) if err != nil { return err } for name, ratio := range checkGroupRatio { + trimmedName := strings.TrimSpace(name) + if isReservedAutoRouteGroupName(trimmedName) { + return errors.New("group name conflicts with auto route namespace: " + trimmedName) + } if ratio < 0 { return errors.New("group ratio must be not less than 0: " + name) } } return nil } + +func isReservedAutoRouteGroupName(name string) bool { + return name == defaultAutoRouteGroupName || strings.HasPrefix(name, autoRouteGroupNamePrefix) +} diff --git a/setting/ratio_setting/group_ratio_test.go b/setting/ratio_setting/group_ratio_test.go new file mode 100644 index 0000000..79b5b08 --- /dev/null +++ b/setting/ratio_setting/group_ratio_test.go @@ -0,0 +1,23 @@ +package ratio_setting + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckGroupRatioRejectsAutoRouteNamespace(t *testing.T) { + for _, jsonStr := range []string{ + `{"auto":1}`, + `{"auto:fast":1}`, + `{" auto:fast ":1}`, + } { + err := CheckGroupRatio(jsonStr) + require.Error(t, err) + require.Contains(t, err.Error(), "auto route namespace") + } +} + +func TestCheckGroupRatioAcceptsNormalGroups(t *testing.T) { + require.NoError(t, CheckGroupRatio(`{"default":1,"vip":0}`)) +} diff --git a/tools/jsonwrapcheck/allowlist.txt b/tools/jsonwrapcheck/allowlist.txt index f8d768a..5958326 100644 --- a/tools/jsonwrapcheck/allowlist.txt +++ b/tools/jsonwrapcheck/allowlist.txt @@ -204,6 +204,5 @@ setting/console_setting/validation.go|getJSONList|Unmarshal|403028caafc69490779d setting/rate_limit.go|ModelRequestRateLimitGroup2JSONString|Marshal|c72783cb7874ede43c0e880006216b4e7a02605d568d41c3360f5da2674d41dc setting/rate_limit.go|UpdateModelRequestRateLimitGroupByJSONString|Unmarshal|7407dc7c4c3c97a8dba391911f2c435cfc531476870e4deef40b633f4cf49463 setting/rate_limit.go|CheckModelRequestRateLimitGroup|Unmarshal|e3bd25b5c46d357559a707ed7efbf4d2659495da494208dce96b6de39f6295d1 -setting/ratio_setting/group_ratio.go|CheckGroupRatio|Unmarshal|f98dddc90d624dc900ef44b6a91deaff0ff747b1c7c89ea90c538dbfb0ddc7d5 setting/user_usable_group.go|UserUsableGroups2JSONString|Marshal|5ab02528b3071cc8ce22854630a142a6ee42cc88ddd9be6965e56fcf6712f0c8 setting/user_usable_group.go|UpdateUserUsableGroupsByJSONString|Unmarshal|f8175ca3d196420af48d2ed1a311b8ee0f25c4d3686b0a83ed673693e4a4580b diff --git a/web/default/src/components/group-badge.tsx b/web/default/src/components/group-badge.tsx index 9686c59..f33d81f 100644 --- a/web/default/src/components/group-badge.tsx +++ b/web/default/src/components/group-badge.tsx @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { useTranslation } from 'react-i18next' +import { DEFAULT_AUTO_ROUTE_KEY, isAutoRouteKey } from '@/lib/auto-routes' import { cn } from '@/lib/utils' import { StatusBadge, type StatusBadgeProps } from './status-badge' @@ -48,7 +49,9 @@ function getGroupLabel(params: { }): string { if (params.labelOverride) return params.labelOverride if (params.isEmptyGroup) return params.t('User Group') - if (params.isAutoGroup) return params.t('Auto') + if (params.isAutoGroup && params.groupName === DEFAULT_AUTO_ROUTE_KEY) { + return params.t('Auto') + } return params.groupName ?? '' } @@ -63,7 +66,7 @@ export function GroupBadge(props: GroupBadgeProps) { ...badgeProps } = props const groupName = group?.trim() - const isAutoGroup = groupName === 'auto' + const isAutoGroup = isAutoRouteKey(groupName) const isEmptyGroup = !groupName const isSpecialGroup = isAutoGroup || isEmptyGroup const label = getGroupLabel({ diff --git a/web/default/src/components/model-group-selector.tsx b/web/default/src/components/model-group-selector.tsx index fb87e1e..3a912bc 100644 --- a/web/default/src/components/model-group-selector.tsx +++ b/web/default/src/components/model-group-selector.tsx @@ -53,11 +53,15 @@ interface ModelOption { interface GroupOption { label: string value: string - ratio?: number + ratio?: number | string desc?: string description?: string } +function shouldShowGroupRatio(ratio: GroupOption['ratio']) { + return ratio !== undefined && ratio !== 0 && ratio !== '0' +} + interface ModelSelectorProps { selectedModel: string models: ModelOption[] @@ -423,7 +427,7 @@ export const GroupSelector: React.FC = React.memo( {(group.desc || group.description) && (
{group.desc || group.description} - {group.ratio && ( + {shouldShowGroupRatio(group.ratio) && ( <> {' · '} {t('Ratio: {{value}}', { value: group.ratio })} @@ -485,7 +489,7 @@ export const GroupSelector: React.FC = React.memo( {(group.desc || group.description) && (
{group.desc || group.description} - {group.ratio && ( + {shouldShowGroupRatio(group.ratio) && ( <> {' · '} {t('Ratio: {{value}}', { diff --git a/web/default/src/features/keys/components/api-keys-columns.tsx b/web/default/src/features/keys/components/api-keys-columns.tsx index b81c110..a80dd1f 100644 --- a/web/default/src/features/keys/components/api-keys-columns.tsx +++ b/web/default/src/features/keys/components/api-keys-columns.tsx @@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query' import { type ColumnDef } from '@tanstack/react-table' import { useTranslation } from 'react-i18next' import { getUserGroups } from '@/lib/api' +import { isAutoRouteKey } from '@/lib/auto-routes' import { formatQuota, formatTimestampToDate } from '@/lib/format' import { cn } from '@/lib/utils' import { Checkbox } from '@/components/ui/checkbox' @@ -199,9 +200,10 @@ export function useApiKeysColumns(): ColumnDef[] { cell: ({ row }) => { const apiKey = row.original const group = row.getValue('group') as string - const ratio = group && group !== 'auto' ? groupRatios[group] : undefined + const isAutoRoute = isAutoRouteKey(group) + const ratio = group && !isAutoRoute ? groupRatios[group] : undefined - if (group === 'auto') { + if (isAutoRoute) { return ( [] { } > - + {apiKey.cross_group_retry && ( g.value === 'auto') + const backendHasDefaultAutoRoute = groups.some( + (g) => g.value === defaultAutoRoute + ) const schema = getApiKeyFormSchema(t) const form = useForm({ resolver: zodResolver(schema), - defaultValues: getApiKeyFormDefaultValues(defaultUseAutoGroup), + defaultValues: getApiKeyFormDefaultValues( + defaultUseAutoGroup, + defaultAutoRoute + ), }) // Load existing data when updating @@ -142,10 +152,21 @@ export function ApiKeysMutateDrawer({ }) } else if (open && !isUpdate) { form.reset( - getApiKeyFormDefaultValues(defaultUseAutoGroup && backendHasAuto) + getApiKeyFormDefaultValues( + defaultUseAutoGroup && backendHasDefaultAutoRoute, + defaultAutoRoute + ) ) } - }, [open, isUpdate, currentRow, form, defaultUseAutoGroup, backendHasAuto]) + }, [ + open, + isUpdate, + currentRow, + form, + defaultUseAutoGroup, + backendHasDefaultAutoRoute, + defaultAutoRoute, + ]) // Correct group after groups load: if the form value is not in available groups, fall back useEffect(() => { @@ -157,7 +178,7 @@ export function ApiKeysMutateDrawer({ groups[0]?.value ?? '' form.setValue('group', fallback) - if (currentGroup === 'auto') { + if (isAutoRouteKey(currentGroup)) { form.setValue('cross_group_retry', false) } } @@ -314,7 +335,7 @@ export function ApiKeysMutateDrawer({ )} /> - {selectedGroup === 'auto' && ( + {isAutoRouteKey(selectedGroup) && ( - modelEnableGroups.includes(g) - ) - - if (autoChain.length === 0) return null + const routes = + props.autoRoutes && props.autoRoutes.length > 0 + ? props.autoRoutes + : [ + { + key: DEFAULT_AUTO_ROUTE_KEY, + enabled: true, + user_selectable: true, + groups: props.autoGroups, + }, + ] + const routeChains = routes + .filter((route) => route.enabled) + .map((route) => ({ + route, + groups: route.groups.filter((g) => modelEnableGroups.includes(g)), + })) + .filter((item) => item.groups.length > 0) + + if (routeChains.length === 0) return null + + const getRouteLabelOverride = (route: AutoGroupRoute) => { + const name = route.name?.trim() + if ( + route.key === DEFAULT_AUTO_ROUTE_KEY && + (!name || name === 'Auto' || name === DEFAULT_AUTO_ROUTE_KEY) + ) { + return undefined + } + return name || route.key + } return ( -
- {t('Auto Group Chain')} - - {autoChain.map((g, idx) => ( - - - {idx < autoChain.length - 1 && ( - - )} - +
+ {t('Auto Route Chains')} + {routeChains.map(({ route, groups }) => ( +
+ + + {groups.map((g, idx) => ( + + + {idx < groups.length - 1 && ( + + )} + + ))} +
))}
) @@ -772,8 +812,9 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) { function GroupPricingSection(props: { model: PricingModel groupRatio: Record - usableGroup: Record + usableGroup: Record autoGroups: string[] + autoRoutes?: AutoGroupRoute[] priceRate: number usdExchangeRate: number tokenUnit: TokenUnit @@ -812,7 +853,11 @@ function GroupPricingSection(props: { return (
{t('Pricing by Group')} - +

{t( 'This model is not available in any group, or no group pricing information is configured.' @@ -830,7 +875,11 @@ function GroupPricingSection(props: { return (

{t('Pricing by Group')} - +
@@ -885,7 +934,11 @@ function GroupPricingSection(props: { return (
{t('Pricing by Group')} - +
{t('Special billing expression')} @@ -927,7 +980,11 @@ function GroupPricingSection(props: { return (
{t('Pricing by Group')} - +
{availableGroups.map((group) => { const ratio = props.groupRatio[group] || 1 @@ -1003,7 +1060,11 @@ function GroupPricingSection(props: { return (
{t('Pricing by Group')} - +
@@ -1131,9 +1192,10 @@ const TAB_META: Record< export interface ModelDetailsContentProps { model: PricingModel groupRatio: Record - usableGroup: Record + usableGroup: Record endpointMap: Record autoGroups: string[] + autoRoutes?: AutoGroupRoute[] priceRate: number usdExchangeRate: number tokenUnit: TokenUnit @@ -1199,6 +1261,7 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) { groupRatio={props.groupRatio} usableGroup={props.usableGroup} autoGroups={props.autoGroups} + autoRoutes={props.autoRoutes} priceRate={props.priceRate} usdExchangeRate={props.usdExchangeRate} tokenUnit={props.tokenUnit} @@ -1277,6 +1340,7 @@ export function ModelDetails() { usableGroup, endpointMap, autoGroups, + autoRoutes, isLoading, priceRate, usdExchangeRate, @@ -1355,6 +1419,7 @@ export function ModelDetails() { groupRatio={groupRatio || {}} usableGroup={usableGroup || {}} autoGroups={autoGroups || []} + autoRoutes={autoRoutes || []} priceRate={priceRate ?? 1} usdExchangeRate={usdExchangeRate ?? 1} tokenUnit={tokenUnit} diff --git a/web/default/src/features/pricing/hooks/use-pricing-data.ts b/web/default/src/features/pricing/hooks/use-pricing-data.ts index 27eb168..11f2650 100644 --- a/web/default/src/features/pricing/hooks/use-pricing-data.ts +++ b/web/default/src/features/pricing/hooks/use-pricing-data.ts @@ -67,6 +67,7 @@ export function usePricingData() { usableGroup: data?.usable_group ?? {}, endpointMap: data?.supported_endpoint ?? {}, autoGroups: data?.auto_groups ?? [], + autoRoutes: data?.auto_routes ?? [], isLoading, error, refetch, diff --git a/web/default/src/features/pricing/index.tsx b/web/default/src/features/pricing/index.tsx index 30dd22b..6fb3f00 100644 --- a/web/default/src/features/pricing/index.tsx +++ b/web/default/src/features/pricing/index.tsx @@ -18,6 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { isAutoRouteKey } from '@/lib/auto-routes' import { PublicLayout } from '@/components/layout' import { PageTransition } from '@/components/page-transition' import { @@ -47,6 +48,7 @@ export function Pricing() { usableGroup, endpointMap, autoGroups, + autoRoutes, isLoading, priceRate, usdExchangeRate, @@ -98,7 +100,7 @@ export function Pricing() { const availableGroups = useMemo( () => Object.keys(usableGroup || {}).filter( - (g) => !EXCLUDED_GROUPS.includes(g) + (g) => !EXCLUDED_GROUPS.includes(g) && !isAutoRouteKey(g) ), [usableGroup] ) @@ -275,6 +277,7 @@ export function Pricing() { >) || {} } autoGroups={autoGroups || []} + autoRoutes={autoRoutes || []} priceRate={priceRate ?? 1} usdExchangeRate={usdExchangeRate ?? 1} tokenUnit={tokenUnit} diff --git a/web/default/src/features/pricing/lib/mock-stats.ts b/web/default/src/features/pricing/lib/mock-stats.ts index b1abb9d..8b9904c 100644 --- a/web/default/src/features/pricing/lib/mock-stats.ts +++ b/web/default/src/features/pricing/lib/mock-stats.ts @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { isAutoRouteKey } from '@/lib/auto-routes' import type { PricingModel } from '../types' import { hashStringToSeed, @@ -309,7 +310,9 @@ function groupFactor( * row for each enabled group, sorted alphabetically. */ export function buildGroupPerformance(model: PricingModel): GroupPerformance[] { - const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto') + const groups = (model.enable_groups ?? []).filter( + (g) => g && !isAutoRouteKey(g) + ) const targets = groups.length > 0 ? groups : ['default'] const profile = PROFILE_BY_NAME(model.model_name) const spec = PROFILE_SPECS[profile] @@ -807,7 +810,9 @@ export type RateLimit = { /** Build per-group RPM / TPM / RPD limits for the model. */ export function buildRateLimits(model: PricingModel): RateLimit[] { - const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto') + const groups = (model.enable_groups ?? []).filter( + (g) => g && !isAutoRouteKey(g) + ) const targets = groups.length > 0 ? groups : ['default'] const cat = apiCategoryOf(model) const baseSeed = hashStringToSeed(`${model.model_name}:rl`) diff --git a/web/default/src/features/pricing/lib/model-helpers.ts b/web/default/src/features/pricing/lib/model-helpers.ts index 60506f0..5557aec 100644 --- a/web/default/src/features/pricing/lib/model-helpers.ts +++ b/web/default/src/features/pricing/lib/model-helpers.ts @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { isAutoRouteKey } from '@/lib/auto-routes' import { EXCLUDED_GROUPS, QUOTA_TYPE_VALUES } from '../constants' import type { PricingModel } from '../types' @@ -28,7 +29,7 @@ import type { PricingModel } from '../types' */ export function getAvailableGroups( model: PricingModel, - usableGroup: Record + usableGroup: Record ): string[] { const modelEnableGroups = Array.isArray(model.enable_groups) ? model.enable_groups @@ -36,6 +37,7 @@ export function getAvailableGroups( return Object.keys(usableGroup) .filter((g) => !EXCLUDED_GROUPS.includes(g)) + .filter((g) => !isAutoRouteKey(g)) .filter((g) => modelEnableGroups.includes(g)) } diff --git a/web/default/src/features/pricing/types.ts b/web/default/src/features/pricing/types.ts index 390b318..b55e24d 100644 --- a/web/default/src/features/pricing/types.ts +++ b/web/default/src/features/pricing/types.ts @@ -16,6 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import type { AutoGroupRoute } from '@/lib/auto-routes' + // ---------------------------------------------------------------------------- // Pricing Types // ---------------------------------------------------------------------------- @@ -117,9 +119,13 @@ export type PricingData = { data: PricingModel[] vendors: PricingVendor[] group_ratio: Record - usable_group: Record + usable_group: Record< + string, + { desc: string; ratio: number | string; auto?: boolean; groups?: string[] } + > supported_endpoint: Record auto_groups: string[] + auto_routes?: AutoGroupRoute[] } export type TokenUnit = 'M' | 'K' diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index ffbf565..7db296c 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -57,6 +57,7 @@ const defaultBillingSettings: BillingSettings = { UserUsableGroups: '', GroupGroupRatio: '', AutoGroups: '', + AutoGroupRoutes: '', DefaultUseAutoGroup: false, 'group_ratio_setting.group_special_usable_group': '{}', PayAddress: '', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index de9380a..c9e4dcf 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -47,6 +47,7 @@ const getGroupDefaults = (settings: BillingSettings) => ({ UserUsableGroups: settings.UserUsableGroups, GroupGroupRatio: settings.GroupGroupRatio, AutoGroups: settings.AutoGroups, + AutoGroupRoutes: settings.AutoGroupRoutes, DefaultUseAutoGroup: settings.DefaultUseAutoGroup, GroupSpecialUsableGroup: settings['group_ratio_setting.group_special_usable_group'], diff --git a/web/default/src/features/system-settings/models/group-ratio-form.tsx b/web/default/src/features/system-settings/models/group-ratio-form.tsx index 9c2edb6..0034939 100644 --- a/web/default/src/features/system-settings/models/group-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/group-ratio-form.tsx @@ -65,6 +65,7 @@ type GroupFormValues = { UserUsableGroups: string GroupGroupRatio: string AutoGroups: string + AutoGroupRoutes: string DefaultUseAutoGroup: boolean GroupSpecialUsableGroup: string } @@ -141,6 +142,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({ userUsableGroups={form.watch('UserUsableGroups')} groupGroupRatio={form.watch('GroupGroupRatio')} autoGroups={form.watch('AutoGroups')} + autoGroupRoutes={form.watch('AutoGroupRoutes')} onChange={(field, value) => handleFieldChange(field as keyof GroupFormValues, value) } @@ -262,13 +264,32 @@ export const GroupRatioForm = memo(function GroupRatioForm({ name='AutoGroups' render={({ field }) => ( - {t('Auto assignment order')} + {t('Default auto route groups')}