From d710003140d864e7c98e97c51e2677cb253fa654 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 5 Aug 2026 20:38:46 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + constant/context_key.go | 2 + controller/group.go | 10 +- controller/model.go | 52 +- controller/model_list_test.go | 15 + controller/model_owned_by_test.go | 18 + controller/token.go | 211 +++++-- controller/token_test.go | 280 ++++++++- middleware/auth.go | 23 +- middleware/distributor.go | 35 +- middleware/distributor_routing_test.go | 114 ++++ model/ability.go | 3 + model/token.go | 3 +- model/token_routing.go | 60 ++ model/token_routing_test.go | 49 ++ service/channel_select.go | 65 +++ service/channel_select_routing_test.go | 145 +++++ service/token_routing.go | 291 ++++++++++ service/token_routing_test.go | 124 ++++ .../components/api-key-group-combobox.tsx | 6 +- .../components/api-key-routing-editor.tsx | 535 ++++++++++++++++++ .../keys/components/api-keys-columns.tsx | 45 ++ .../components/api-keys-mutate-drawer.tsx | 286 +++++++--- .../features/keys/lib/api-key-form.test.ts | 212 +++++++ .../src/features/keys/lib/api-key-form.ts | 170 +++++- web/default/src/features/keys/lib/index.ts | 2 + web/default/src/features/keys/types.ts | 20 +- web/default/src/i18n/locales/en.json | 33 +- web/default/src/i18n/locales/fr.json | 33 +- web/default/src/i18n/locales/ja.json | 33 +- web/default/src/i18n/locales/ru.json | 33 +- web/default/src/i18n/locales/vi.json | 33 +- web/default/src/i18n/locales/zh.json | 33 +- web/default/src/i18n/static-keys.ts | 1 - web/default/src/lib/api.ts | 2 + 35 files changed, 2765 insertions(+), 214 deletions(-) create mode 100644 middleware/distributor_routing_test.go create mode 100644 model/token_routing.go create mode 100644 model/token_routing_test.go create mode 100644 service/channel_select_routing_test.go create mode 100644 service/token_routing.go create mode 100644 service/token_routing_test.go create mode 100644 web/default/src/features/keys/components/api-key-routing-editor.tsx create mode 100644 web/default/src/features/keys/lib/api-key-form.test.ts diff --git a/.gitignore b/.gitignore index 91fd4e39..529ad2bd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ skills-lock.json .playwright-mcp Operation.md /home +.tools + diff --git a/constant/context_key.go b/constant/context_key.go index ee074936..732864dd 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -19,6 +19,8 @@ const ( ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" ContextKeyTokenModelLimit ContextKey = "token_model_limit" ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" + ContextKeyTokenRoutingPolicy ContextKey = "token_routing_policy" + ContextKeyTokenRoutePlan ContextKey = "token_route_plan" /* channel related keys */ ContextKeyChannelId ContextKey = "channel_id" diff --git a/controller/group.go b/controller/group.go index cdc783e9..3b02c1a9 100644 --- a/controller/group.go +++ b/controller/group.go @@ -38,13 +38,15 @@ func GetUserGroups(c *gin.Context) { } } } - for _, route := range service.GetUserAutoRoutes(userGroup, true) { + autoRoutes := service.GetUserAutoRoutes(userGroup, true) + for _, route := range autoRoutes { addAutoRouteUsableGroup(usableGroups, route) } c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": usableGroups, + "success": true, + "message": "", + "data": usableGroups, + "auto_routes": autoRoutes, }) } diff --git a/controller/model.go b/controller/model.go index 16966bb6..62d6bb80 100644 --- a/controller/model.go +++ b/controller/model.go @@ -186,16 +186,34 @@ func getModelListGroups(c *gin.Context) (modelListGroups, error) { return modelListGroups{}, err } } - return buildModelListGroups(userGroup, tokenGroup), nil + return buildModelListGroupsWithOwnerGroups( + userGroup, + tokenGroup, + service.GetContextTokenRouteGroups(c, userGroup, tokenGroup), + ), nil } func getContextModelListGroups(c *gin.Context) modelListGroups { - return buildModelListGroups( - common.GetContextKeyString(c, constant.ContextKeyUserGroup), - common.GetContextKeyString(c, constant.ContextKeyTokenGroup), + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + return buildModelListGroupsWithOwnerGroups( + userGroup, + tokenGroup, + service.GetContextTokenRouteGroups(c, userGroup, tokenGroup), ) } +func buildModelListGroupsWithOwnerGroups(userGroup string, tokenGroup string, ownerGroups []string) modelListGroups { + if len(ownerGroups) > 0 { + return modelListGroups{ + userGroup: userGroup, + tokenGroup: tokenGroup, + ownerGroups: ownerGroups, + } + } + return buildModelListGroups(userGroup, tokenGroup) +} + func buildModelListGroups(userGroup string, tokenGroup string) modelListGroups { if setting.IsAutoRouteKey(tokenGroup) { return modelListGroups{ @@ -222,6 +240,18 @@ func buildModelListGroups(userGroup string, tokenGroup string) modelListGroups { } } +func getEnabledModelsForGroups(ownerGroups []string) []string { + models := make([]string, 0) + for _, ownerGroup := range ownerGroups { + for _, groupModel := range model.GetGroupEnabledModels(ownerGroup) { + if !common.StringsContains(models, groupModel) { + models = append(models, groupModel) + } + } + } + return models +} + func ListModels(c *gin.Context, modelType int) { acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled if !acceptUnsetRatioModel { @@ -269,19 +299,7 @@ func ListModels(c *gin.Context, modelType int) { userModelNames = append(userModelNames, allowModel) } } else { - var models []string - if setting.IsAutoRouteKey(groups.tokenGroup) { - for _, autoGroup := range ownerGroups { - groupModels := model.GetGroupEnabledModels(autoGroup) - for _, g := range groupModels { - if !common.StringsContains(models, g) { - models = append(models, g) - } - } - } - } else if len(ownerGroups) > 0 { - models = model.GetGroupEnabledModels(ownerGroups[0]) - } + models := getEnabledModelsForGroups(ownerGroups) for _, modelName := range models { if !acceptUnsetRatioModel { if !helper.HasModelBillingConfig(modelName) { diff --git a/controller/model_list_test.go b/controller/model_list_test.go index 916a07d5..c225c64a 100644 --- a/controller/model_list_test.go +++ b/controller/model_list_test.go @@ -213,6 +213,21 @@ func TestListModelsIncludesTieredBillingModel(t *testing.T) { require.Empty(t, missingExprPricing.BillingExpr) } +func TestGetEnabledModelsForGroupsIncludesModelsFromEveryOwnerGroup(t *testing.T) { + db := setupModelListControllerTestDB(t) + require.NoError(t, db.Create(&[]model.Ability{ + {Group: "vip", Model: "zz-manual-first-model", ChannelId: 1, Enabled: true}, + {Group: "default", Model: "zz-manual-later-model", ChannelId: 2, Enabled: true}, + {Group: "default", Model: "zz-manual-first-model", ChannelId: 3, Enabled: true}, + }).Error) + + models := getEnabledModelsForGroups([]string{"vip", "default"}) + require.ElementsMatch(t, []string{ + "zz-manual-first-model", + "zz-manual-later-model", + }, models) +} + func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) { withSelfUseModeDisabled(t) withTieredBillingConfig(t, map[string]string{ diff --git a/controller/model_owned_by_test.go b/controller/model_owned_by_test.go index 6b637a74..d0458421 100644 --- a/controller/model_owned_by_test.go +++ b/controller/model_owned_by_test.go @@ -6,6 +6,7 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/model" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -83,3 +84,20 @@ func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) { require.Equal(t, "vip", groups.tokenGroup) require.Equal(t, []string{"vip"}, groups.ownerGroups) } + +func TestGetModelListGroupsUsesManualTokenRoutingGroups(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "vip") + common.SetContextKey(ctx, constant.ContextKeyTokenRoutingPolicy, model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeManual, + Groups: []string{"vip", "default"}, + RetryOnFailure: true, + }) + + groups, err := getModelListGroups(ctx) + require.NoError(t, err) + require.Equal(t, []string{"vip", "default"}, groups.ownerGroups) +} diff --git a/controller/token.go b/controller/token.go index 215f7413..45791a9e 100644 --- a/controller/token.go +++ b/controller/token.go @@ -1,6 +1,8 @@ package controller import ( + "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -15,39 +17,114 @@ import ( "github.com/gin-gonic/gin" ) -func buildMaskedTokenResponse(token *model.Token) *model.Token { +type tokenResponse struct { + *model.Token + Routing *model.TokenRoutingPolicy `json:"routing,omitempty"` + RoutingLegacy bool `json:"routing_legacy,omitempty"` +} + +func buildMaskedTokenResponse(token *model.Token, userGroup string) *tokenResponse { if token == nil { return nil } maskedToken := *token maskedToken.Key = token.GetMaskedKey() - return &maskedToken + routing, err := token.GetStoredRoutingPolicy() + if err != nil { + common.SysLog(fmt.Sprintf("failed to decode token routing policy (tokenId=%d): %s", token.Id, err.Error())) + } + routingLegacy := routing == nil && err == nil + if routingLegacy { + legacy := service.LegacyTokenRoutingPolicy(token.Group, token.CrossGroupRetry, userGroup) + routing = &legacy + } + return &tokenResponse{Token: &maskedToken, Routing: routing, RoutingLegacy: routingLegacy} } -func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token { - maskedTokens := make([]*model.Token, 0, len(tokens)) +func buildMaskedTokenResponses(tokens []*model.Token, userGroup string) []*tokenResponse { + maskedTokens := make([]*tokenResponse, 0, len(tokens)) for _, token := range tokens { - maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token)) + maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token, userGroup)) } return maskedTokens } +type tokenMutationRequest struct { + Id int `json:"id"` + Status int `json:"status"` + Name string `json:"name"` + ExpiredTime int64 `json:"expired_time"` + RemainQuota int64 `json:"remain_quota"` + UnlimitedQuota bool `json:"unlimited_quota"` + ModelLimitsEnabled bool `json:"model_limits_enabled"` + ModelLimits string `json:"model_limits"` + AllowIps *string `json:"allow_ips"` + Group *string `json:"group"` + CrossGroupRetry bool `json:"cross_group_retry"` + Routing json.RawMessage `json:"routing"` +} + +func requestUserGroup(c *gin.Context) string { + userGroup := c.GetString("group") + if userGroup == "" { + userGroup = c.GetString("user_group") + } + return userGroup +} + 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.CanUseTokenGroupRuntime(userGroup, group) { + if service.CanUseTokenGroupRuntime(requestUserGroup(c), group) { return true } common.ApiErrorI18n(c, i18n.MsgTokenGroupNotAssignable, map[string]any{"Group": group}) return false } +func resolveTokenMutationRouting(request tokenMutationRequest, current *model.Token, userGroup string, creating bool) (model.TokenRoutingPolicy, error) { + if len(request.Routing) > 0 { + if strings.TrimSpace(string(request.Routing)) == "null" { + return service.NormalizeLegacyTokenRoutingPolicy(service.DefaultTokenRoutingPolicy(), userGroup) + } + var policy model.TokenRoutingPolicy + if err := common.Unmarshal(request.Routing, &policy); err != nil { + return model.TokenRoutingPolicy{}, fmt.Errorf("invalid token routing policy: %w", err) + } + return service.NormalizeTokenRoutingPolicy(policy, userGroup) + } + + if request.Group != nil { + return service.NormalizeLegacyTokenRoutingPolicy( + service.LegacyTokenRoutingPolicy(*request.Group, request.CrossGroupRetry, userGroup), + userGroup, + ) + } + if creating { + return service.NormalizeLegacyTokenRoutingPolicy(service.DefaultTokenRoutingPolicy(), userGroup) + } + if current == nil { + return model.TokenRoutingPolicy{}, errors.New("current token is required") + } + stored, err := current.GetStoredRoutingPolicy() + if err != nil { + return model.TokenRoutingPolicy{}, fmt.Errorf("invalid token routing policy: %w", err) + } + if stored != nil { + return stored.Clone(), nil + } + return service.NormalizeLegacyTokenRoutingPolicy( + service.LegacyTokenRoutingPolicy(current.Group, current.CrossGroupRetry, userGroup), + userGroup, + ) +} + +func tokenMutationUpdatesRouting(request tokenMutationRequest) bool { + return len(request.Routing) > 0 || request.Group != nil +} + func GetAllTokens(c *gin.Context) { userId := c.GetInt("id") pageInfo := common.GetPageQuery(c) @@ -58,7 +135,7 @@ func GetAllTokens(c *gin.Context) { } total, _ := model.CountUserTokens(userId) pageInfo.SetTotal(int(total)) - pageInfo.SetItems(buildMaskedTokenResponses(tokens)) + pageInfo.SetItems(buildMaskedTokenResponses(tokens, requestUserGroup(c))) common.ApiSuccess(c, pageInfo) } @@ -75,7 +152,7 @@ func SearchTokens(c *gin.Context) { return } pageInfo.SetTotal(int(total)) - pageInfo.SetItems(buildMaskedTokenResponses(tokens)) + pageInfo.SetItems(buildMaskedTokenResponses(tokens, requestUserGroup(c))) common.ApiSuccess(c, pageInfo) } @@ -91,7 +168,7 @@ func GetToken(c *gin.Context) { common.ApiError(c, err) return } - common.ApiSuccess(c, buildMaskedTokenResponse(token)) + common.ApiSuccess(c, buildMaskedTokenResponse(token, requestUserGroup(c))) } func GetTokenKey(c *gin.Context) { @@ -165,28 +242,33 @@ func GetTokenUsage(c *gin.Context) { } func AddToken(c *gin.Context) { - token := model.Token{} - err := c.ShouldBindJSON(&token) + request := tokenMutationRequest{} + err := c.ShouldBindJSON(&request) if err != nil { common.ApiError(c, err) return } - if len(token.Name) > 50 { + if len(request.Name) > 50 { common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return } - token.Group = strings.TrimSpace(token.Group) - if !validateAssignableTokenGroup(c, token.Group) { + if request.Group != nil && !validateAssignableTokenGroup(c, *request.Group) { return } + routing, err := resolveTokenMutationRouting(request, nil, requestUserGroup(c), true) + if err != nil { + common.ApiError(c, err) + return + } + projectedGroup, crossGroupRetry := service.ProjectTokenRoutingPolicy(routing) // 非无限额度时,检查额度值是否超出有效范围 - if !token.UnlimitedQuota { - if token.RemainQuota < 0 { + if !request.UnlimitedQuota { + if request.RemainQuota < 0 { common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative) return } maxQuotaValue := int64(1000000000 * common.QuotaPerUnit) - if token.RemainQuota > maxQuotaValue { + if request.RemainQuota > maxQuotaValue { common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue}) return } @@ -213,18 +295,22 @@ func AddToken(c *gin.Context) { } cleanToken := model.Token{ UserId: c.GetInt("id"), - Name: token.Name, + Name: request.Name, Key: key, CreatedTime: common.GetTimestamp(), AccessedTime: common.GetTimestamp(), - ExpiredTime: token.ExpiredTime, - RemainQuota: token.RemainQuota, - UnlimitedQuota: token.UnlimitedQuota, - ModelLimitsEnabled: token.ModelLimitsEnabled, - ModelLimits: token.ModelLimits, - AllowIps: token.AllowIps, - Group: token.Group, - CrossGroupRetry: token.CrossGroupRetry, + ExpiredTime: request.ExpiredTime, + RemainQuota: request.RemainQuota, + UnlimitedQuota: request.UnlimitedQuota, + ModelLimitsEnabled: request.ModelLimitsEnabled, + ModelLimits: request.ModelLimits, + AllowIps: request.AllowIps, + Group: projectedGroup, + CrossGroupRetry: crossGroupRetry, + } + if err := cleanToken.SetRoutingPolicy(&routing); err != nil { + common.ApiError(c, err) + return } err = cleanToken.Insert() if err != nil { @@ -254,45 +340,45 @@ func DeleteToken(c *gin.Context) { func UpdateToken(c *gin.Context) { userId := c.GetInt("id") statusOnly := c.Query("status_only") - token := model.Token{} - err := c.ShouldBindJSON(&token) + request := tokenMutationRequest{} + err := c.ShouldBindJSON(&request) if err != nil { common.ApiError(c, err) return } // Full updates omit status with its zero value. Status-only updates must persist a valid status. - if (statusOnly != "" || token.Status != 0) && !isValidTokenStatus(token.Status) { + if (statusOnly != "" || request.Status != 0) && !isValidTokenStatus(request.Status) { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } - if len(token.Name) > 50 { + if len(request.Name) > 50 { common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return } - if !token.UnlimitedQuota { - if token.RemainQuota < 0 { + if !request.UnlimitedQuota { + if request.RemainQuota < 0 { common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative) return } maxQuotaValue := int64(1000000000 * common.QuotaPerUnit) - if token.RemainQuota > maxQuotaValue { + if request.RemainQuota > maxQuotaValue { common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue}) return } } - cleanToken, err := model.GetTokenByIds(token.Id, userId) + cleanToken, err := model.GetTokenByIds(request.Id, userId) if err != nil { common.ApiError(c, err) return } - if token.Status == common.TokenStatusEnabled { + if request.Status == common.TokenStatusEnabled { expiredTime := cleanToken.ExpiredTime remainQuota := cleanToken.RemainQuota unlimitedQuota := cleanToken.UnlimitedQuota if statusOnly == "" { - expiredTime = token.ExpiredTime - remainQuota = token.RemainQuota - unlimitedQuota = token.UnlimitedQuota + expiredTime = request.ExpiredTime + remainQuota = request.RemainQuota + unlimitedQuota = request.UnlimitedQuota } if cleanToken.Status == common.TokenStatusExpired && expiredTime <= common.GetTimestamp() && expiredTime != -1 { common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable) @@ -304,24 +390,35 @@ func UpdateToken(c *gin.Context) { } } if statusOnly != "" { - cleanToken.Status = token.Status + cleanToken.Status = request.Status } else { - token.Group = strings.TrimSpace(token.Group) - if !validateAssignableTokenGroup(c, token.Group) { + if request.Group != nil && !validateAssignableTokenGroup(c, *request.Group) { return } // If you add more fields, please also update token.Update() - cleanToken.Name = token.Name - cleanToken.ExpiredTime = token.ExpiredTime - cleanToken.RemainQuota = token.RemainQuota - cleanToken.UnlimitedQuota = token.UnlimitedQuota - cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled - cleanToken.ModelLimits = token.ModelLimits - cleanToken.AllowIps = token.AllowIps - cleanToken.Group = token.Group - cleanToken.CrossGroupRetry = token.CrossGroupRetry - if token.Status != 0 { - cleanToken.Status = token.Status + cleanToken.Name = request.Name + cleanToken.ExpiredTime = request.ExpiredTime + cleanToken.RemainQuota = request.RemainQuota + cleanToken.UnlimitedQuota = request.UnlimitedQuota + cleanToken.ModelLimitsEnabled = request.ModelLimitsEnabled + cleanToken.ModelLimits = request.ModelLimits + cleanToken.AllowIps = request.AllowIps + if tokenMutationUpdatesRouting(request) { + routing, routingErr := resolveTokenMutationRouting(request, cleanToken, requestUserGroup(c), false) + if routingErr != nil { + common.ApiError(c, routingErr) + return + } + projectedGroup, crossGroupRetry := service.ProjectTokenRoutingPolicy(routing) + cleanToken.Group = projectedGroup + cleanToken.CrossGroupRetry = crossGroupRetry + if err := cleanToken.SetRoutingPolicy(&routing); err != nil { + common.ApiError(c, err) + return + } + } + if request.Status != 0 { + cleanToken.Status = request.Status } } err = cleanToken.Update() @@ -332,7 +429,7 @@ func UpdateToken(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": buildMaskedTokenResponse(cleanToken), + "data": buildMaskedTokenResponse(cleanToken, requestUserGroup(c)), }) } diff --git a/controller/token_test.go b/controller/token_test.go index fc45b7f2..4cb8caac 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -16,6 +16,7 @@ import ( 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/MAX-API-Next/MAX-API/setting/ratio_setting" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "gorm.io/driver/mysql" @@ -34,10 +35,14 @@ type tokenPageResponse struct { } type tokenResponseItem struct { - ID int `json:"id"` - Name string `json:"name"` - Key string `json:"key"` - Status int `json:"status"` + ID int `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + Status int `json:"status"` + Group string `json:"group"` + CrossGroupRetry bool `json:"cross_group_retry"` + Routing *model.TokenRoutingPolicy `json:"routing"` + RoutingLegacy bool `json:"routing_legacy"` } type tokenKeyResponse struct { @@ -125,6 +130,7 @@ func setupHiddenAutoRouteForTokenTest(t *testing.T) { t.Fatalf("failed to marshal user group snapshot: %v", err) } autoRoutesSnapshot := setting.AutoGroupRoutes2JsonString() + groupRatioSnapshot := ratio_setting.GroupRatio2JSONString() t.Cleanup(func() { if err := setting.UpdateUserUsableGroupsByJSONString(string(userGroupsBytes)); err != nil { t.Fatalf("failed to restore user groups: %v", err) @@ -132,11 +138,17 @@ func setupHiddenAutoRouteForTokenTest(t *testing.T) { if err := setting.UpdateAutoGroupRoutesByJsonString(autoRoutesSnapshot); err != nil { t.Fatalf("failed to restore auto routes: %v", err) } + if err := ratio_setting.UpdateGroupRatioByJSONString(groupRatioSnapshot); err != nil { + t.Fatalf("failed to restore group ratios: %v", err) + } }) if err := setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`); err != nil { t.Fatalf("failed to set user usable groups: %v", err) } + if err := ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":2}`); err != nil { + t.Fatalf("failed to set group ratios: %v", err) + } if err := setting.UpdateAutoGroupRoutesByJsonString(`{ "version": 1, "default_route": "auto", @@ -522,6 +534,60 @@ func TestGetTokenMasksKeyInResponse(t *testing.T) { } } +func TestGetTokenSynthesizesLegacyRoutingForEditing(t *testing.T) { + db := setupTokenControllerTestDB(t) + setupHiddenAutoRouteForTokenTest(t) + + autoToken := seedToken(t, db, 1, "legacy-auto", "auto1234token5678") + if err := db.Model(autoToken).Updates(map[string]any{ + "group": "auto", + "cross_group_retry": true, + }).Error; err != nil { + t.Fatalf("failed to prepare legacy auto token: %v", err) + } + + ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(autoToken.Id), nil, 1) + ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(autoToken.Id)}} + setAuthenticatedUserGroup(ctx, "default") + GetToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected legacy auto token response, got %q", response.Message) + } + var detail tokenResponseItem + if err := common.Unmarshal(response.Data, &detail); err != nil { + t.Fatalf("failed to decode legacy auto token response: %v", err) + } + if !detail.RoutingLegacy || detail.Routing == nil || detail.Routing.Mode != model.TokenRoutingModeSmart || detail.Routing.Route != "auto" { + t.Fatalf("expected synthesized legacy auto routing, got %#v", detail) + } + + emptyToken := seedToken(t, db, 1, "legacy-empty", "empty1234token5678") + if err := db.Model(emptyToken).Update("group", "").Error; err != nil { + t.Fatalf("failed to prepare empty-group token: %v", err) + } + ctx, recorder = newAuthenticatedContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(emptyToken.Id), nil, 1) + ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(emptyToken.Id)}} + setAuthenticatedUserGroup(ctx, "vip") + GetToken(ctx) + + response = decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected legacy empty-group token response, got %q", response.Message) + } + detail = tokenResponseItem{} + if err := common.Unmarshal(response.Data, &detail); err != nil { + t.Fatalf("failed to decode legacy empty-group token response: %v", err) + } + if !detail.RoutingLegacy || detail.Routing == nil || detail.Routing.Mode != model.TokenRoutingModeManual || len(detail.Routing.Groups) != 1 || detail.Routing.Groups[0] != "vip" { + t.Fatalf("expected empty legacy group to resolve to the authenticated user group, got %#v", detail) + } + if detail.Group != "" { + t.Fatalf("expected stored legacy group projection to remain empty, got %q", detail.Group) + } +} + func TestAddTokenAllowsRuntimeAutoRoute(t *testing.T) { db := setupTokenControllerTestDB(t) setupHiddenAutoRouteForTokenTest(t) @@ -559,6 +625,212 @@ func TestAddTokenAllowsRuntimeAutoRoute(t *testing.T) { } } +func TestAddTokenRejectsNonSelectableExplicitSmartRoute(t *testing.T) { + db := setupTokenControllerTestDB(t) + setupHiddenAutoRouteForTokenTest(t) + + body := map[string]any{ + "name": "hidden-explicit-route-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "routing": map[string]any{ + "version": 1, + "mode": "smart", + "route": "auto:internal", + "strategy": "balanced", + "retry_on_failure": true, + }, + } + + ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + AddToken(ctx) + + response := decodeAPIResponse(t, recorder) + if response.Success { + t.Fatal("expected non-selectable explicit smart route to be rejected") + } + 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 TestAddTokenDefaultsToSmartRouting(t *testing.T) { + db := setupTokenControllerTestDB(t) + + body := map[string]any{ + "name": "smart-default-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + } + ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + AddToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected token creation to succeed, got %q", response.Message) + } + + var stored model.Token + if err := db.First(&stored).Error; err != nil { + t.Fatalf("failed to load created token: %v", err) + } + policy, err := stored.GetStoredRoutingPolicy() + if err != nil { + t.Fatalf("failed to decode stored routing policy: %v", err) + } + if policy == nil || policy.Mode != model.TokenRoutingModeSmart || policy.Route != setting.GetDefaultAutoRouteKey() { + t.Fatalf("expected default smart routing policy, got %#v", policy) + } + if stored.Group != setting.GetDefaultAutoRouteKey() || !stored.CrossGroupRetry { + t.Fatalf("expected legacy projection to default auto route with retry, got group=%q retry=%v", stored.Group, stored.CrossGroupRetry) + } +} + +func TestAddTokenPersistsManualRoutingOrder(t *testing.T) { + db := setupTokenControllerTestDB(t) + setupHiddenAutoRouteForTokenTest(t) + + body := map[string]any{ + "name": "manual-route-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "routing": map[string]any{ + "version": 1, + "mode": "manual", + "groups": []string{"vip", "default"}, + "retry_on_failure": true, + }, + } + ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + AddToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected manual token creation to succeed, got %q", response.Message) + } + var stored model.Token + if err := db.First(&stored).Error; err != nil { + t.Fatalf("failed to load created token: %v", err) + } + policy, err := stored.GetStoredRoutingPolicy() + if err != nil { + t.Fatalf("failed to decode stored routing policy: %v", err) + } + if policy == nil || len(policy.Groups) != 2 || policy.Groups[0] != "vip" || policy.Groups[1] != "default" { + t.Fatalf("expected ordered manual groups, got %#v", policy) + } + if stored.Group != "vip" || !stored.CrossGroupRetry { + t.Fatalf("expected legacy projection to first manual group, got group=%q retry=%v", stored.Group, stored.CrossGroupRetry) + } +} + +func TestResolveTokenMutationRoutingPreservesStoredPolicyWhenRoutingIsOmitted(t *testing.T) { + current := &model.Token{} + storedPolicy := model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeManual, + Groups: []string{"removed", "default"}, + RetryOnFailure: true, + } + if err := current.SetRoutingPolicy(&storedPolicy); err != nil { + t.Fatalf("failed to store routing policy: %v", err) + } + + resolved, err := resolveTokenMutationRouting(tokenMutationRequest{}, current, "default", false) + if err != nil { + t.Fatalf("expected omitted routing to preserve the stored policy: %v", err) + } + if len(resolved.Groups) != 2 || resolved.Groups[0] != "removed" || resolved.Groups[1] != "default" { + t.Fatalf("expected stored routing order to remain unchanged, got %#v", resolved.Groups) + } +} + +func TestUpdateTokenPreservesLegacyRoutingWhenRoutingFieldsAreOmitted(t *testing.T) { + db := setupTokenControllerTestDB(t) + token := seedToken(t, db, 1, "legacy-empty", "legacy1234empty5678") + if err := db.Model(token).Updates(map[string]any{ + "group": "", + "cross_group_retry": true, + }).Error; err != nil { + t.Fatalf("failed to prepare legacy token: %v", err) + } + + body := map[string]any{ + "id": token.Id, + "name": "legacy-empty-updated", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "allow_ips": "", + } + ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "vip") + UpdateToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected legacy token update to succeed, got %q", response.Message) + } + var stored model.Token + if err := db.First(&stored, token.Id).Error; err != nil { + t.Fatalf("failed to reload legacy token: %v", err) + } + if stored.Group != "" || !stored.CrossGroupRetry || stored.RoutingPolicyJSON != nil { + t.Fatalf("expected exact legacy routing storage to be preserved, got group=%q retry=%v policy=%v", stored.Group, stored.CrossGroupRetry, stored.RoutingPolicyJSON) + } +} + +func TestAddTokenRejectsEmptyManualRouting(t *testing.T) { + db := setupTokenControllerTestDB(t) + body := map[string]any{ + "name": "empty-manual-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "routing": map[string]any{ + "version": 1, + "mode": "manual", + "groups": []string{}, + "retry_on_failure": true, + }, + } + ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + AddToken(ctx) + + response := decodeAPIResponse(t, recorder) + if response.Success { + t.Fatal("expected empty manual routing to fail") + } + 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 TestAddTokenRejectsUnavailableGroup(t *testing.T) { db := setupTokenControllerTestDB(t) if err := appi18n.Init(); err != nil { diff --git a/middleware/auth.go b/middleware/auth.go index b3699c27..f7a4e6d0 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -14,7 +14,6 @@ import ( "github.com/MAX-API-Next/MAX-API/logger" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/service" - "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-contrib/sessions" @@ -466,18 +465,13 @@ func TokenAuth() func(c *gin.Context) { userCache.WriteContext(c) userGroup := userCache.Group - tokenGroup := token.Group + routingPolicy, legacyRouting, routingErr := service.ResolveTokenRoutingPolicy(token, userGroup) + if routingErr != nil { + abortWithOpenAiMessage(c, http.StatusForbidden, routingErr.Error()) + return + } + tokenGroup, crossGroupRetry := service.ProjectTokenRoutingPolicy(routingPolicy) if tokenGroup != "" { - // check common.UserUsableGroups[userGroup] - if !service.CanUseTokenGroupRuntime(userGroup, tokenGroup) { - abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("无权访问 %s 分组", tokenGroup)) - return - } - // check group in common.GroupRatio - if !service.IsAutoRouteKey(tokenGroup) && !ratio_setting.ContainsGroupRatio(tokenGroup) { - abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) - return - } userGroup = tokenGroup } common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup) @@ -486,6 +480,11 @@ func TokenAuth() func(c *gin.Context) { if err != nil { return } + common.SetContextKey(c, constant.ContextKeyTokenGroup, tokenGroup) + common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, crossGroupRetry) + if !legacyRouting { + common.SetContextKey(c, constant.ContextKeyTokenRoutingPolicy, routingPolicy) + } c.Next() } } diff --git a/middleware/distributor.go b/middleware/distributor.go index 54063c1f..4cdb89e3 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -83,6 +83,7 @@ func Distribute() func(c *gin.Context) { } var selectGroup string usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + playgroundGroupOverride := false // check path is /pg/chat/completions if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { playgroundRequest := &dto.PlayGroundRequest{} @@ -99,15 +100,44 @@ func Distribute() func(c *gin.Context) { } usingGroup = playgroundRequest.Group common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) + playgroundGroupOverride = true + } + } + var routePlan *service.TokenRoutePlan + if !playgroundGroupOverride { + routePlan, err = service.BuildContextTokenRoutePlan(c) + if err != nil { + abortWithOpenAiMessage(c, http.StatusForbidden, err.Error()) + return } } - if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { + affinityGroup := usingGroup + if routePlan != nil && !routePlan.Legacy && len(routePlan.OrderedGroups) > 0 { + affinityGroup = routePlan.OrderedGroups[0] + } + if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, affinityGroup); found { affinityUsable := false preferred, err := model.CacheGetChannel(preferredChannelID) if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled && channelSupportsRequestPath(preferred, c.Request.URL.Path) { - if service.IsAutoRouteKey(usingGroup) { + if routePlan != nil && !routePlan.Legacy && len(routePlan.OrderedGroups) > 0 { + firstGroup := routePlan.OrderedGroups[0] + if model.IsChannelEnabledForGroupModel(firstGroup, modelRequest.Model, preferred.Id) { + selectGroup = firstGroup + common.SetContextKey(c, constant.ContextKeyAutoGroup, firstGroup) + channel = preferred + affinityUsable = true + service.MarkChannelAffinityUsed(c, firstGroup, preferred.Id) + if routePlan.RetryOnFailure && len(routePlan.OrderedGroups) > 1 { + common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, 1) + common.SetContextKey(c, constant.ContextKeyAutoGroupRetryIndex, 1) + } else { + common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, 0) + common.SetContextKey(c, constant.ContextKeyAutoGroupRetryIndex, 0) + } + } + } else if service.IsAutoRouteKey(usingGroup) { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) autoGroups := service.GetUserAutoGroupByRoute(userGroup, usingGroup) for _, g := range autoGroups { @@ -218,7 +248,6 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) { if err != nil { return nil, err } - if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil { return nil, seekErr } diff --git a/middleware/distributor_routing_test.go b/middleware/distributor_routing_test.go new file mode 100644 index 00000000..70cde522 --- /dev/null +++ b/middleware/distributor_routing_test.go @@ -0,0 +1,114 @@ +package middleware + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/setting" + "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "gorm.io/gorm" +) + +func TestGetModelFromJSONBodyPreservesProviderRoutingFields(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{ + "model":"gpt-test", + "routing_strategy":"cost", + "provider":{"sort":"price","order":["openai"]} + }`)) + ctx.Request.Header.Set("Content-Type", "application/json") + t.Cleanup(func() { common.CleanupBodyStorage(ctx) }) + + request, err := getModelFromJSONBody(ctx) + require.NoError(t, err) + require.Equal(t, "gpt-test", request.Model) + + storage, err := common.GetBodyStorage(ctx) + require.NoError(t, err) + body, err := storage.Bytes() + require.NoError(t, err) + require.Equal(t, "cost", gjson.GetBytes(body, "routing_strategy").String()) + require.Equal(t, "price", gjson.GetBytes(body, "provider.sort").String()) + require.Equal(t, "openai", gjson.GetBytes(body, "provider.order.0").String()) +} + +func TestPlaygroundExplicitGroupBypassesStoredTokenRoutePlan(t *testing.T) { + previousDB := model.DB + previousMemoryCache := common.MemoryCacheEnabled + usableGroups := setting.GetUserUsableGroupsCopy() + usableGroupsJSON, err := common.Marshal(usableGroups) + require.NoError(t, err) + groupRatiosJSON := ratio_setting.GroupRatio2JSONString() + t.Cleanup(func() { + model.DB = previousDB + common.MemoryCacheEnabled = previousMemoryCache + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(string(usableGroupsJSON))) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(groupRatiosJSON)) + }) + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{})) + model.DB = db + common.MemoryCacheEnabled = false + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","base":"Base","deluxe":"Deluxe"}`)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"base":1,"deluxe":2}`)) + + modelName := "gpt-playground-route" + for index, group := range []string{"base", "deluxe"} { + channelID := index + 1 + priority := int64(0) + require.NoError(t, db.Create(&model.Channel{ + Id: channelID, + Type: 1, + Key: fmt.Sprintf("key-%d", channelID), + Status: common.ChannelStatusEnabled, + Name: group, + Models: modelName, + Group: group, + }).Error) + require.NoError(t, db.Create(&model.Ability{ + Group: group, + Model: modelName, + ChannelId: channelID, + Enabled: true, + Priority: &priority, + Weight: 100, + }).Error) + } + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/pg/chat/completions", bytes.NewBufferString(fmt.Sprintf(`{"model":%q,"group":"deluxe"}`, modelName))) + ctx.Request.Header.Set("Content-Type", "application/json") + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyUsingGroup, "base") + common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "base") + common.SetContextKey(ctx, constant.ContextKeyTokenRoutingPolicy, model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeManual, + Groups: []string{"base"}, + RetryOnFailure: true, + }) + t.Cleanup(func() { common.CleanupBodyStorage(ctx) }) + + Distribute()(ctx) + + require.False(t, ctx.IsAborted()) + require.Equal(t, "deluxe", common.GetContextKeyString(ctx, constant.ContextKeyUsingGroup)) + require.Equal(t, 2, common.GetContextKeyInt(ctx, constant.ContextKeyChannelId)) + _, hasPlan := common.GetContextKey(ctx, constant.ContextKeyTokenRoutePlan) + require.False(t, hasPlan) +} diff --git a/model/ability.go b/model/ability.go index c5c54e63..18b20818 100644 --- a/model/ability.go +++ b/model/ability.go @@ -186,6 +186,9 @@ func selectChannelIdFromAbilities(abilities []Ability, retry int, excludedChanne sort.Slice(priorities, func(i, j int) bool { return priorities[i] > priorities[j] }) + if len(priorities) == 0 { + return 0, false + } if retry >= len(priorities) { retry = len(priorities) - 1 } diff --git a/model/token.go b/model/token.go index 2d23a30f..f2876e8e 100644 --- a/model/token.go +++ b/model/token.go @@ -28,6 +28,7 @@ type Token struct { UsedQuota int64 `json:"used_quota" gorm:"type:bigint;default:0"` // used quota Group string `json:"group" gorm:"default:''"` CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效 + RoutingPolicyJSON *string `json:"-" gorm:"column:routing_policy;type:text"` DeletedAt gorm.DeletedAt `gorm:"index"` } @@ -311,7 +312,7 @@ func (token *Token) Update() (err error) { var cacheTask CacheInvalidationTask err = DB.Transaction(func(tx *gorm.DB) error { if err := tx.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; err != nil { + "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "routing_policy").Updates(token).Error; err != nil { return err } var err error diff --git a/model/token_routing.go b/model/token_routing.go new file mode 100644 index 00000000..287d3513 --- /dev/null +++ b/model/token_routing.go @@ -0,0 +1,60 @@ +package model + +import ( + "bytes" + "errors" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" +) + +const ( + TokenRoutingPolicyVersion = 1 + + TokenRoutingModeSmart = "smart" + TokenRoutingModeManual = "manual" +) + +type TokenRoutingPolicy struct { + Version int `json:"version"` + Mode string `json:"mode"` + Route string `json:"route,omitempty"` + Groups []string `json:"groups,omitempty"` + RetryOnFailure bool `json:"retry_on_failure"` +} + +func (policy TokenRoutingPolicy) Clone() TokenRoutingPolicy { + policy.Groups = append([]string(nil), policy.Groups...) + return policy +} + +func (token *Token) GetStoredRoutingPolicy() (*TokenRoutingPolicy, error) { + if token == nil || token.RoutingPolicyJSON == nil || strings.TrimSpace(*token.RoutingPolicyJSON) == "" { + return nil, nil + } + + var policy TokenRoutingPolicy + if err := common.Unmarshal([]byte(*token.RoutingPolicyJSON), &policy); err != nil { + return nil, err + } + return &policy, nil +} + +func (token *Token) SetRoutingPolicy(policy *TokenRoutingPolicy) error { + if token == nil { + return errors.New("token is nil") + } + if policy == nil { + token.RoutingPolicyJSON = nil + return nil + } + + payload, err := common.Marshal(policy) + if err != nil { + return err + } + payload = bytes.TrimSpace(payload) + encoded := string(payload) + token.RoutingPolicyJSON = &encoded + return nil +} diff --git a/model/token_routing_test.go b/model/token_routing_test.go new file mode 100644 index 00000000..b3de07f4 --- /dev/null +++ b/model/token_routing_test.go @@ -0,0 +1,49 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTokenRoutingPolicyRoundTrip(t *testing.T) { + token := &Token{} + policy := TokenRoutingPolicy{ + Version: TokenRoutingPolicyVersion, + Mode: TokenRoutingModeManual, + Groups: []string{"vip", "default"}, + RetryOnFailure: true, + } + + require.NoError(t, token.SetRoutingPolicy(&policy)) + stored, err := token.GetStoredRoutingPolicy() + require.NoError(t, err) + require.Equal(t, policy, *stored) + + stored.Groups[0] = "changed" + require.Equal(t, "vip", policy.Groups[0]) +} + +func TestTokenRoutingPolicyIgnoresRemovedStrategyFields(t *testing.T) { + raw := `{"version":1,"mode":"smart","route":"auto","strategy":"speed","allow_request_override":true,"retry_on_failure":true}` + token := &Token{RoutingPolicyJSON: &raw} + + stored, err := token.GetStoredRoutingPolicy() + require.NoError(t, err) + require.Equal(t, TokenRoutingPolicy{ + Version: TokenRoutingPolicyVersion, + Mode: TokenRoutingModeSmart, + Route: "auto", + RetryOnFailure: true, + }, *stored) +} + +func TestTokenRoutingPolicyNilPreservesLegacyStorage(t *testing.T) { + token := &Token{RoutingPolicyJSON: nil} + stored, err := token.GetStoredRoutingPolicy() + require.NoError(t, err) + require.Nil(t, stored) + + require.NoError(t, token.SetRoutingPolicy(nil)) + require.Nil(t, token.RoutingPolicyJSON) +} diff --git a/service/channel_select.go b/service/channel_select.go index f8071ad4..2882290e 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -99,6 +99,9 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, var err error selectGroup := param.TokenGroup userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup) + if routePlan, ok := GetTokenRoutePlan(param.Ctx); ok && !routePlan.Legacy { + return selectChannelFromTokenRoutePlan(param, routePlan) + } if setting.IsAutoRouteKey(param.TokenGroup) { autoGroups := GetUserAutoGroupByRoute(userGroup, param.TokenGroup) @@ -177,3 +180,65 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, } return channel, selectGroup, nil } + +func selectChannelFromTokenRoutePlan(param *RetryParam, plan *TokenRoutePlan) (*model.Channel, string, error) { + if plan == nil || len(plan.OrderedGroups) == 0 { + return nil, param.TokenGroup, errors.New("token route plan has no available groups") + } + + startGroupIndex := 0 + if raw, ok := common.GetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex); ok { + if index, valid := raw.(int); valid && index >= 0 && index < len(plan.OrderedGroups) { + startGroupIndex = index + } + } + groupStartRetry := param.GetRetry() + if raw, ok := common.GetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex); ok { + if retry, valid := raw.(int); valid && retry >= 0 && retry <= param.GetRetry() { + groupStartRetry = retry + } + } + + selectGroup := plan.OrderedGroups[startGroupIndex] + for index := startGroupIndex; index < len(plan.OrderedGroups); index++ { + group := plan.OrderedGroups[index] + selectGroup = group + priorityRetry := param.GetRetry() - groupStartRetry + if index > startGroupIndex || priorityRetry < 0 { + priorityRetry = 0 + groupStartRetry = param.GetRetry() + } + + logger.LogDebug(param.Ctx, "Token route plan selecting group: %s, priorityRetry: %d", group, priorityRetry) + channel, err := model.GetRandomSatisfiedChannelExcluding( + group, + param.ModelName, + priorityRetry, + param.RequestPath, + param.excludedChannelIDs, + ) + if err != nil { + return nil, group, err + } + if channel == nil { + if param.GetRetry() > 0 && !plan.RetryOnFailure { + return nil, group, nil + } + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, index+1) + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, param.GetRetry()) + continue + } + + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, group) + if plan.RetryOnFailure && index < len(plan.OrderedGroups)-1 { + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, index+1) + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, param.GetRetry()+1) + } else { + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, index) + common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, groupStartRetry) + } + return channel, group, nil + } + + return nil, selectGroup, nil +} diff --git a/service/channel_select_routing_test.go b/service/channel_select_routing_test.go new file mode 100644 index 00000000..fb15fa74 --- /dev/null +++ b/service/channel_select_routing_test.go @@ -0,0 +1,145 @@ +package service + +import ( + "fmt" + "net/http/httptest" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestTokenRoutePlanAdvancesGroupsWithoutResettingRetryBudget(t *testing.T) { + previousDB := model.DB + previousMemoryCache := common.MemoryCacheEnabled + t.Cleanup(func() { + model.DB = previousDB + common.MemoryCacheEnabled = previousMemoryCache + }) + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{})) + model.DB = db + common.MemoryCacheEnabled = false + + modelName := "gpt-route-plan" + for index, group := range []string{"base", "deluxe"} { + channelID := index + 1 + priority := int64(0) + require.NoError(t, db.Create(&model.Channel{ + Id: channelID, + Type: 1, + Key: fmt.Sprintf("key-%d", channelID), + Status: common.ChannelStatusEnabled, + Name: group, + Models: modelName, + Group: group, + }).Error) + require.NoError(t, db.Create(&model.Ability{ + Group: group, + Model: modelName, + ChannelId: channelID, + Enabled: true, + Priority: &priority, + Weight: 100, + }).Error) + } + + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(ctx, constant.ContextKeyTokenRoutePlan, &TokenRoutePlan{ + Mode: "manual", + OrderedGroups: []string{"base", "deluxe"}, + RetryOnFailure: true, + }) + retry := 0 + param := &RetryParam{ + Ctx: ctx, + TokenGroup: "base", + ModelName: modelName, + RequestPath: "/v1/chat/completions", + Retry: &retry, + } + + first, firstGroup, err := CacheGetRandomSatisfiedChannel(param) + require.NoError(t, err) + require.NotNil(t, first) + require.Equal(t, "base", firstGroup) + require.Equal(t, 0, param.GetRetry()) + + param.ExcludeChannel(first.Id) + param.SetRetry(1) + second, secondGroup, err := CacheGetRandomSatisfiedChannel(param) + require.NoError(t, err) + require.NotNil(t, second) + require.Equal(t, "deluxe", secondGroup) + require.Equal(t, 1, param.GetRetry()) +} + +func TestTokenRoutePlanDoesNotAdvanceAfterFailureWhenRetryDisabled(t *testing.T) { + previousDB := model.DB + previousMemoryCache := common.MemoryCacheEnabled + t.Cleanup(func() { + model.DB = previousDB + common.MemoryCacheEnabled = previousMemoryCache + }) + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{})) + model.DB = db + common.MemoryCacheEnabled = false + + modelName := "gpt-route-plan-no-cross-group-retry" + for index, group := range []string{"base", "deluxe"} { + channelID := index + 1 + priority := int64(0) + require.NoError(t, db.Create(&model.Channel{ + Id: channelID, + Type: 1, + Key: fmt.Sprintf("key-%d", channelID), + Status: common.ChannelStatusEnabled, + Name: group, + Models: modelName, + Group: group, + }).Error) + require.NoError(t, db.Create(&model.Ability{ + Group: group, + Model: modelName, + ChannelId: channelID, + Enabled: true, + Priority: &priority, + Weight: 100, + }).Error) + } + + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(ctx, constant.ContextKeyTokenRoutePlan, &TokenRoutePlan{ + Mode: "manual", + OrderedGroups: []string{"base", "deluxe"}, + RetryOnFailure: false, + }) + retry := 1 + param := &RetryParam{ + Ctx: ctx, + TokenGroup: "base", + ModelName: modelName, + RequestPath: "/v1/chat/completions", + Retry: &retry, + } + param.ExcludeChannel(1) + + channel, group, err := CacheGetRandomSatisfiedChannel(param) + require.NoError(t, err) + require.Nil(t, channel) + require.Equal(t, "base", group) + _, advanced := common.GetContextKey(ctx, constant.ContextKeyAutoGroupIndex) + require.False(t, advanced) +} diff --git a/service/token_routing.go b/service/token_routing.go new file mode 100644 index 00000000..72ca1da0 --- /dev/null +++ b/service/token_routing.go @@ -0,0 +1,291 @@ +package service + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/setting" + "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" + "github.com/gin-gonic/gin" +) + +const MaxTokenManualGroups = 8 + +type TokenRoutePlan struct { + PolicyVersion int + Legacy bool + Mode string + Route string + OrderedGroups []string + RetryOnFailure bool +} + +type tokenRoutingValidationOptions struct { + requireSelectableRoute bool + skipUnavailableGroups bool +} + +func LegacyTokenRoutingPolicy(group string, crossGroupRetry bool, userGroup string) model.TokenRoutingPolicy { + group = strings.TrimSpace(group) + if setting.IsAutoRouteKey(group) { + return model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeSmart, + Route: group, + RetryOnFailure: crossGroupRetry, + } + } + if group == "" { + group = strings.TrimSpace(userGroup) + } + return model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeManual, + Groups: []string{group}, + RetryOnFailure: crossGroupRetry, + } +} + +func DefaultTokenRoutingPolicy() model.TokenRoutingPolicy { + return model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeSmart, + Route: setting.GetDefaultAutoRouteKey(), + RetryOnFailure: true, + } +} + +func ResolveTokenRoutingPolicy(token *model.Token, userGroup string) (model.TokenRoutingPolicy, bool, error) { + if token == nil { + return model.TokenRoutingPolicy{}, false, errors.New("token is nil") + } + stored, err := token.GetStoredRoutingPolicy() + if err != nil { + return model.TokenRoutingPolicy{}, false, fmt.Errorf("invalid token routing policy: %w", err) + } + if stored == nil { + policy := LegacyTokenRoutingPolicy(token.Group, token.CrossGroupRetry, userGroup) + normalized, err := normalizeTokenRoutingPolicyForRuntime(policy, userGroup) + return normalized, true, err + } + normalized, err := normalizeTokenRoutingPolicyForRuntime(*stored, userGroup) + return normalized, false, err +} + +func NormalizeTokenRoutingPolicy(policy model.TokenRoutingPolicy, userGroup string) (model.TokenRoutingPolicy, error) { + return normalizeTokenRoutingPolicy(policy, userGroup, tokenRoutingValidationOptions{ + requireSelectableRoute: true, + }) +} + +func NormalizeLegacyTokenRoutingPolicy(policy model.TokenRoutingPolicy, userGroup string) (model.TokenRoutingPolicy, error) { + return normalizeTokenRoutingPolicy(policy, userGroup, tokenRoutingValidationOptions{}) +} + +func normalizeTokenRoutingPolicyForRuntime(policy model.TokenRoutingPolicy, userGroup string) (model.TokenRoutingPolicy, error) { + return normalizeTokenRoutingPolicy(policy, userGroup, tokenRoutingValidationOptions{ + skipUnavailableGroups: true, + }) +} + +func normalizeTokenRoutingPolicy(policy model.TokenRoutingPolicy, userGroup string, options tokenRoutingValidationOptions) (model.TokenRoutingPolicy, error) { + if policy.Version == 0 { + policy.Version = model.TokenRoutingPolicyVersion + } + if policy.Version != model.TokenRoutingPolicyVersion { + return model.TokenRoutingPolicy{}, fmt.Errorf("unsupported token routing policy version: %d", policy.Version) + } + + policy.Mode = strings.TrimSpace(policy.Mode) + policy.Route = strings.TrimSpace(policy.Route) + + switch policy.Mode { + case model.TokenRoutingModeSmart: + if len(policy.Groups) != 0 { + return model.TokenRoutingPolicy{}, errors.New("smart routing must not define manual groups") + } + if policy.Route == "" { + policy.Route = setting.GetDefaultAutoRouteKey() + } + if !setting.IsAutoRouteKey(policy.Route) { + return model.TokenRoutingPolicy{}, fmt.Errorf("invalid smart route: %s", policy.Route) + } + if _, ok := GetUserAutoRoute(userGroup, policy.Route, options.requireSelectableRoute); !ok { + return model.TokenRoutingPolicy{}, fmt.Errorf("auto route %s is not available", policy.Route) + } + policy.Groups = nil + case model.TokenRoutingModeManual: + if policy.Route != "" { + return model.TokenRoutingPolicy{}, errors.New("manual routing must not define a smart route") + } + if len(policy.Groups) == 0 { + return model.TokenRoutingPolicy{}, errors.New("manual routing requires at least one group") + } + if len(policy.Groups) > MaxTokenManualGroups { + return model.TokenRoutingPolicy{}, fmt.Errorf("manual routing must not exceed %d groups", MaxTokenManualGroups) + } + seen := make(map[string]struct{}, len(policy.Groups)) + groups := make([]string, 0, len(policy.Groups)) + for _, rawGroup := range policy.Groups { + group := strings.TrimSpace(rawGroup) + if group == "" { + return model.TokenRoutingPolicy{}, errors.New("manual routing groups must not be empty") + } + if setting.IsAutoRouteKey(group) { + return model.TokenRoutingPolicy{}, fmt.Errorf("manual routing requires real groups only: %s", group) + } + if _, exists := seen[group]; exists { + return model.TokenRoutingPolicy{}, fmt.Errorf("duplicate manual routing group: %s", group) + } + seen[group] = struct{}{} + if !CanUseTokenGroupRuntime(userGroup, group) || !ratio_setting.ContainsGroupRatio(group) { + if options.skipUnavailableGroups { + continue + } + return model.TokenRoutingPolicy{}, fmt.Errorf("group %s is not available", group) + } + groups = append(groups, group) + } + if len(groups) == 0 { + return model.TokenRoutingPolicy{}, errors.New("manual routing has no available groups") + } + policy.Groups = groups + default: + return model.TokenRoutingPolicy{}, fmt.Errorf("unsupported token routing mode: %s", policy.Mode) + } + + return policy, nil +} + +func ProjectTokenRoutingPolicy(policy model.TokenRoutingPolicy) (group string, crossGroupRetry bool) { + if policy.Mode == model.TokenRoutingModeSmart { + return policy.Route, policy.RetryOnFailure + } + if len(policy.Groups) > 0 { + return policy.Groups[0], policy.RetryOnFailure + } + return "", policy.RetryOnFailure +} + +func BuildTokenRoutePlan(policy model.TokenRoutingPolicy, userGroup string) (TokenRoutePlan, error) { + policy, err := normalizeTokenRoutingPolicyForRuntime(policy, userGroup) + if err != nil { + return TokenRoutePlan{}, err + } + + plan := TokenRoutePlan{ + PolicyVersion: policy.Version, + Mode: policy.Mode, + Route: policy.Route, + RetryOnFailure: policy.RetryOnFailure, + } + if policy.Mode == model.TokenRoutingModeManual { + plan.OrderedGroups = slices.Clone(policy.Groups) + return plan, nil + } + + groups := GetUserAutoGroupByRoute(userGroup, policy.Route) + if len(groups) == 0 { + return TokenRoutePlan{}, fmt.Errorf("auto route %s has no available groups", policy.Route) + } + plan.OrderedGroups = slices.Clone(groups) + return plan, nil +} + +func BuildContextTokenRoutePlan(c *gin.Context) (*TokenRoutePlan, error) { + if c == nil { + return nil, errors.New("request context is nil") + } + if existing, ok := GetTokenRoutePlan(c); ok { + return existing, nil + } + + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + var policy model.TokenRoutingPolicy + _, hasStoredPolicy := common.GetContextKey(c, constant.ContextKeyTokenRoutingPolicy) + if raw, ok := common.GetContextKey(c, constant.ContextKeyTokenRoutingPolicy); ok { + switch value := raw.(type) { + case model.TokenRoutingPolicy: + policy = value.Clone() + case *model.TokenRoutingPolicy: + if value != nil { + policy = value.Clone() + } + } + } + if policy.Version == 0 { + policy = LegacyTokenRoutingPolicy( + common.GetContextKeyString(c, constant.ContextKeyTokenGroup), + common.GetContextKeyBool(c, constant.ContextKeyTokenCrossGroupRetry), + userGroup, + ) + } + + plan, err := BuildTokenRoutePlan(policy, userGroup) + if err != nil { + return nil, err + } + plan.Legacy = !hasStoredPolicy + common.SetContextKey(c, constant.ContextKeyTokenRoutePlan, &plan) + return &plan, nil +} + +func GetTokenRoutePlan(c *gin.Context) (*TokenRoutePlan, bool) { + if c == nil { + return nil, false + } + raw, ok := common.GetContextKey(c, constant.ContextKeyTokenRoutePlan) + if !ok { + return nil, false + } + plan, ok := raw.(*TokenRoutePlan) + return plan, ok && plan != nil +} + +func GetContextTokenRoutingPolicy(c *gin.Context) (model.TokenRoutingPolicy, bool) { + if c == nil { + return model.TokenRoutingPolicy{}, false + } + raw, ok := common.GetContextKey(c, constant.ContextKeyTokenRoutingPolicy) + if !ok { + return model.TokenRoutingPolicy{}, false + } + switch value := raw.(type) { + case model.TokenRoutingPolicy: + return value.Clone(), true + case *model.TokenRoutingPolicy: + if value != nil { + return value.Clone(), true + } + } + return model.TokenRoutingPolicy{}, false +} + +func GetContextTokenRouteGroups(c *gin.Context, userGroup string, tokenGroup string) []string { + if plan, ok := GetTokenRoutePlan(c); ok && len(plan.OrderedGroups) > 0 { + return slices.Clone(plan.OrderedGroups) + } + if policy, ok := GetContextTokenRoutingPolicy(c); ok { + if policy.Mode == model.TokenRoutingModeManual { + return slices.Clone(policy.Groups) + } + if policy.Mode == model.TokenRoutingModeSmart { + return GetUserAutoGroupByRoute(userGroup, policy.Route) + } + } + if setting.IsAutoRouteKey(tokenGroup) { + return GetUserAutoGroupByRoute(userGroup, tokenGroup) + } + if tokenGroup != "" { + return []string{tokenGroup} + } + if userGroup != "" { + return []string{userGroup} + } + return nil +} diff --git a/service/token_routing_test.go b/service/token_routing_test.go new file mode 100644 index 00000000..877709c0 --- /dev/null +++ b/service/token_routing_test.go @@ -0,0 +1,124 @@ +package service + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/setting" + "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" + "github.com/stretchr/testify/require" +) + +func setupTokenRoutingTestGroups(t *testing.T) { + t.Helper() + + usableGroups := setting.GetUserUsableGroupsCopy() + usableGroupsJSON, err := common.Marshal(usableGroups) + require.NoError(t, err) + autoRoutesJSON := setting.AutoGroupRoutes2JsonString() + groupRatiosJSON := ratio_setting.GroupRatio2JSONString() + t.Cleanup(func() { + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(string(usableGroupsJSON))) + require.NoError(t, setting.UpdateAutoGroupRoutesByJsonString(autoRoutesJSON)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(groupRatiosJSON)) + }) + + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":2}`)) + require.NoError(t, setting.UpdateAutoGroupRoutesByJsonString(`{ + "version": 1, + "default_route": "auto", + "routes": [ + {"key":"auto","name":"Auto","enabled":true,"user_selectable":true,"groups":["default","vip"]}, + {"key":"auto:internal","name":"Internal","enabled":true,"user_selectable":false,"groups":["vip"]} + ] + }`)) +} + +func TestSmartTokenRoutePlanUsesConfiguredAutomaticGroupOrder(t *testing.T) { + setupTokenRoutingTestGroups(t) + + plan, err := BuildTokenRoutePlan(model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeSmart, + Route: "auto", + RetryOnFailure: true, + }, "default") + require.NoError(t, err) + require.Equal(t, []string{"default", "vip"}, plan.OrderedGroups) + require.True(t, plan.RetryOnFailure) +} + +func TestLegacyEmptyGroupUsesAuthenticatedUserGroup(t *testing.T) { + setupTokenRoutingTestGroups(t) + + policy := LegacyTokenRoutingPolicy("", false, "vip") + require.Equal(t, model.TokenRoutingModeManual, policy.Mode) + require.Equal(t, []string{"vip"}, policy.Groups) + + plan, err := BuildTokenRoutePlan(policy, "vip") + require.NoError(t, err) + require.Equal(t, []string{"vip"}, plan.OrderedGroups) +} + +func TestResolveTokenRoutingPolicySkipsUnavailableStoredManualGroups(t *testing.T) { + setupTokenRoutingTestGroups(t) + token := &model.Token{} + require.NoError(t, token.SetRoutingPolicy(&model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeManual, + Groups: []string{"missing", "vip", "default"}, + RetryOnFailure: true, + })) + + policy, legacy, err := ResolveTokenRoutingPolicy(token, "default") + require.NoError(t, err) + require.False(t, legacy) + require.Equal(t, []string{"vip", "default"}, policy.Groups) +} + +func TestResolveTokenRoutingPolicyFailsWhenNoStoredManualGroupsRemain(t *testing.T) { + setupTokenRoutingTestGroups(t) + token := &model.Token{} + require.NoError(t, token.SetRoutingPolicy(&model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeManual, + Groups: []string{"missing"}, + })) + + _, _, err := ResolveTokenRoutingPolicy(token, "default") + require.ErrorContains(t, err, "no available groups") +} + +func TestExplicitSmartRoutingRequiresUserSelectableRoute(t *testing.T) { + setupTokenRoutingTestGroups(t) + policy := model.TokenRoutingPolicy{ + Version: model.TokenRoutingPolicyVersion, + Mode: model.TokenRoutingModeSmart, + Route: "auto:internal", + } + + _, err := NormalizeTokenRoutingPolicy(policy, "default") + require.ErrorContains(t, err, "not available") + _, err = NormalizeLegacyTokenRoutingPolicy(policy, "default") + require.NoError(t, err) +} + +func TestProjectTokenRoutingPolicyKeepsLegacyGroupProjection(t *testing.T) { + group, retry := ProjectTokenRoutingPolicy(model.TokenRoutingPolicy{ + Mode: model.TokenRoutingModeManual, + Groups: []string{"vip", "default"}, + RetryOnFailure: true, + }) + require.Equal(t, "vip", group) + require.True(t, retry) + + group, retry = ProjectTokenRoutingPolicy(model.TokenRoutingPolicy{ + Mode: model.TokenRoutingModeSmart, + Route: "auto", + RetryOnFailure: false, + }) + require.Equal(t, "auto", group) + require.False(t, retry) +} diff --git a/web/default/src/features/keys/components/api-key-group-combobox.tsx b/web/default/src/features/keys/components/api-key-group-combobox.tsx index 55da2462..31959dea 100644 --- a/web/default/src/features/keys/components/api-key-group-combobox.tsx +++ b/web/default/src/features/keys/components/api-key-group-combobox.tsx @@ -76,7 +76,11 @@ function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) { 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'] }) { +export function GroupRatioBadge({ + ratio, +}: { + ratio: ApiKeyGroupOption['ratio'] +}) { const { t } = useTranslation() const label = formatGroupRatio(ratio, t('Ratio')) diff --git a/web/default/src/features/keys/components/api-key-routing-editor.tsx b/web/default/src/features/keys/components/api-key-routing-editor.tsx new file mode 100644 index 00000000..810d4fbf --- /dev/null +++ b/web/default/src/features/keys/components/api-key-routing-editor.tsx @@ -0,0 +1,535 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +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. +*/ +import { + useEffect, + useMemo, + useState, + type KeyboardEvent, + type PointerEvent, +} from 'react' +import { + ArrowDown, + ArrowUp, + ChevronsUpDown, + CircleAlert, + GripVertical, + Route, + Trash2, + X, +} from 'lucide-react' +import { Reorder, useDragControls } from 'motion/react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Skeleton } from '@/components/ui/skeleton' +import { Switch } from '@/components/ui/switch' +import { MAX_MANUAL_ROUTING_GROUPS } from '../lib/api-key-form' +import type { TokenRoutingMode } from '../types' +import { + GroupRatioBadge, + type ApiKeyGroupOption, +} from './api-key-group-combobox' + +export type ApiKeyAutoRouteOption = { + value: string + label: string + groups: string[] +} + +type ApiKeyRoutingEditorProps = { + mode: TokenRoutingMode + route: string + manualGroups: string[] + retryOnFailure: boolean + autoRouteOptions: ApiKeyAutoRouteOption[] + realGroupOptions: ApiKeyGroupOption[] + defaultManualGroups: string[] + preserveUnavailableRouting?: boolean + routesLoading?: boolean + disabled?: boolean + onModeChange: (mode: TokenRoutingMode) => void + onRouteChange: (route: string) => void + onManualGroupsChange: (groups: string[]) => void + onRetryOnFailureChange: (enabled: boolean) => void +} + +type ManualGroupRowProps = { + group: string + index: number + count: number + option?: ApiKeyGroupOption + disabled?: boolean + onMove: (index: number, direction: 'up' | 'down') => void + onRemove: (group: string) => void +} + +function ManualGroupRow(props: ManualGroupRowProps) { + const { t } = useTranslation() + const controls = useDragControls() + const invalid = !props.option + + const startDrag = (event: PointerEvent) => { + if (!props.disabled) controls.start(event) + } + + const handleDragKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowUp') { + event.preventDefault() + props.onMove(props.index, 'up') + } + if (event.key === 'ArrowDown') { + event.preventDefault() + props.onMove(props.index, 'down') + } + } + + return ( + + + + {props.index + 1} + + + {props.option?.label || props.group} + + {invalid ? ( + {t('Unavailable')} + ) : ( + + )} +
+ + + +
+
+ ) +} + +function ManualGroupEditor({ + value, + options, + disabled, + onChange, +}: { + value: string[] + options: ApiKeyGroupOption[] + disabled?: boolean + onChange: (groups: string[]) => void +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const optionMap = useMemo( + () => new Map(options.map((option) => [option.value, option])), + [options] + ) + const atLimit = value.length >= MAX_MANUAL_ROUTING_GROUPS + + useEffect(() => { + if (!disabled) return + const timeoutId = window.setTimeout(() => setOpen(false), 0) + return () => window.clearTimeout(timeoutId) + }, [disabled]) + + const toggleGroup = (group: string) => { + if (disabled) return + if (value.includes(group)) { + onChange(value.filter((item) => item !== group)) + return + } + if (!atLimit) onChange([...value, group]) + } + + const moveGroup = (index: number, direction: 'up' | 'down') => { + if (disabled) return + const target = direction === 'up' ? index - 1 : index + 1 + if (target < 0 || target >= value.length) return + const next = [...value] + ;[next[index], next[target]] = [next[target], next[index]] + onChange(next) + } + + return ( +
+
+ {t('Manual group order')} + + {t('{{count}} / {{max}} groups selected', { + count: value.length, + max: MAX_MANUAL_ROUTING_GROUPS, + })} + +
+ { + if (!disabled) setOpen(nextOpen) + }} + > + + } + > + {value.length === 0 && ( + + {t('Select groups')} + + )} + {value.map((group) => ( + + {group} + + + ))} + + + + + + + {t('No groups found')} + + {options.map((option) => { + const selected = value.includes(option.value) + return ( + toggleGroup(option.value)} + > + + + {option.label} + + {option.desc && ( + + {option.desc} + + )} + + + + ) + })} + + + + + + +

+ {t('Requests try groups in the order shown below.')} +

+ {value.length === 0 ? ( +
+ {t('Select at least one manual routing group')} +
+ ) : ( + { + if (!disabled) onChange(groups) + }} + className='overflow-hidden rounded-md border' + > + {value.map((group, index) => ( + + onChange(value.filter((item) => item !== removed)) + } + /> + ))} + + )} +
+ ) +} + +export function ApiKeyRoutingEditor(props: ApiKeyRoutingEditorProps) { + const { t } = useTranslation() + const smart = props.mode === 'smart' + const activeAutoRoute = props.autoRouteOptions.find( + (option) => option.value === props.route + ) + + const setMode = (mode: TokenRoutingMode) => { + if (mode === 'manual' && props.manualGroups.length === 0) { + props.onManualGroupsChange( + props.defaultManualGroups.slice(0, MAX_MANUAL_ROUTING_GROUPS) + ) + } + props.onModeChange(mode) + } + + return ( +
+
+
+ + {smart ? ( +
+
+
+

+ {t('Automatic routing group')} +

+

+ {t( + 'Choose a system-defined group chain. Requests follow its configured order.' + )} +

+
+ {props.routesLoading ? ( +
+ + +
+ ) : props.autoRouteOptions.length === 0 ? ( + + + ) : ( + + {props.autoRouteOptions.map((option) => { + const selected = props.route === option.value + return ( + + ) + })} + + )} + {props.route && !props.routesLoading && !activeAutoRoute && ( + + + )} +
+
+ ) : ( + + )} + +
+
+

{t('Cross-group retry')}

+

+ {smart + ? t( + 'If the current group fails, try the next group in the configured automatic route order.' + ) + : t( + 'If the current group fails, try the next group in the manual order.' + )} +

+
+ +
+
+ ) +} 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 c8cb675e..7edd06d5 100644 --- a/web/default/src/features/keys/components/api-keys-columns.tsx +++ b/web/default/src/features/keys/components/api-keys-columns.tsx @@ -200,6 +200,51 @@ export function useApiKeysColumns(): ColumnDef[] { cell: ({ row }) => { const apiKey = row.original const group = row.getValue('group') as string + const routing = apiKey.routing + if (routing?.mode === 'smart') { + const route = routing.route || group + return ( + + + } + > + + {routing.retry_on_failure && ( + + )} + + + {t('System route: {{route}}', { + route, + })} + + + ) + } + if (routing?.mode === 'manual') { + const groups = routing.groups || [] + const preview = groups.slice(0, 3).join(' -> ') + return ( + + } + > + {t('Manual {{count}} groups: {{groups}}', { + count: groups.length, + groups: preview, + })} + {groups.length > 3 ? ' ...' : ''} + + {groups.join(' -> ')} + + ) + } const isAutoRoute = isAutoRouteKey(group) const ratio = group && !isAutoRoute ? groupRatios[group] : undefined diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx index b3634079..75a605f1 100644 --- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useForm, type SubmitErrorHandler } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery } from '@tanstack/react-query' @@ -24,7 +24,7 @@ import { ChevronDown, KeyRound, Settings2, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { getUserModels, getUserGroups } from '@/lib/api' -import { DEFAULT_AUTO_ROUTE_KEY, isAutoRouteKey } from '@/lib/auto-routes' +import { DEFAULT_AUTO_ROUTE_KEY } from '@/lib/auto-routes' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' import { cn } from '@/lib/utils' import { useStatus } from '@/hooks/use-status' @@ -70,16 +70,19 @@ import { createApiKey, updateApiKey, getApiKey } from '../api' import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' import { getApiKeyFormSchema, + MAX_MANUAL_ROUTING_GROUPS, + shouldIncludeRoutingProjection, type ApiKeyFormValues, getApiKeyFormDefaultValues, transformFormDataToPayload, transformApiKeyToFormDefaults, } from '../lib' import { type ApiKey } from '../types' +import { type ApiKeyGroupOption } from './api-key-group-combobox' import { - ApiKeyGroupCombobox, - type ApiKeyGroupOption, -} from './api-key-group-combobox' + ApiKeyRoutingEditor, + type ApiKeyAutoRouteOption, +} from './api-key-routing-editor' import { useApiKeys } from './api-keys-provider' type ApiKeyMutateDrawerProps = { @@ -99,7 +102,11 @@ export function ApiKeysMutateDrawer({ const { status } = useStatus() const [isSubmitting, setIsSubmitting] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) - const defaultUseAutoGroup = status?.default_use_auto_group === true + const [editingLegacyRouting, setEditingLegacyRouting] = useState(false) + const [preservedSmartRoute, setPreservedSmartRoute] = useState() + const [preservedManualGroups, setPreservedManualGroups] = useState( + [] + ) const defaultAutoRoute = typeof status?.default_auto_route === 'string' ? status.default_auto_route @@ -113,49 +120,131 @@ export function ApiKeysMutateDrawer({ }) // Fetch groups - const { data: groupsData } = useQuery({ + const { data: groupsData, isLoading: groupsLoading } = useQuery({ queryKey: ['user-groups'], queryFn: getUserGroups, staleTime: 5 * 60 * 1000, }) const models = modelsData?.data || [] - const groupsRaw = groupsData?.data || {} - const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map( - ([key, info]) => ({ - value: key, - label: key, - desc: info.desc || key, - ratio: info.ratio, - }) - ) - const backendHasDefaultAutoRoute = groups.some( - (g) => g.value === defaultAutoRoute + const groupsRaw = useMemo(() => groupsData?.data || {}, [groupsData?.data]) + const { + realGroups, + autoRouteOptions, + effectiveAutoRoute, + defaultManualGroups, + } = useMemo(() => { + const entries = Object.entries(groupsRaw) + const real: ApiKeyGroupOption[] = entries + .filter(([, info]) => info.auto !== true) + .map(([key, info]) => ({ + value: key, + label: key, + desc: info.desc || key, + ratio: info.ratio, + })) + const realGroupNames = new Set(real.map((option) => option.value)) + const serverRoutes = groupsData?.auto_routes || [] + const routes: ApiKeyAutoRouteOption[] = ( + serverRoutes.length > 0 + ? serverRoutes.map((route) => ({ + value: route.key, + label: route.name || route.key, + groups: route.groups, + })) + : entries + .filter(([, info]) => info.auto === true) + .map(([key, info]) => ({ + value: key, + label: info.desc || key, + groups: info.groups || [], + })) + ) + .map((route) => ({ + ...route, + groups: route.groups.filter((group) => realGroupNames.has(group)), + })) + .filter((route) => route.groups.length > 0) + const route = routes.some((option) => option.value === defaultAutoRoute) + ? defaultAutoRoute + : routes[0]?.value || '' + const routeGroups = + routes.find((option) => option.value === route)?.groups.slice(0, 8) || [] + return { + realGroups: real, + autoRouteOptions: routes, + effectiveAutoRoute: route, + defaultManualGroups: + routeGroups.length > 0 + ? routeGroups + : real.slice(0, 1).map((option) => option.value), + } + }, [defaultAutoRoute, groupsData?.auto_routes, groupsRaw]) + const schema = useMemo( + () => + getApiKeyFormSchema(t, { + smartRoutes: autoRouteOptions.map((route) => route.value), + manualGroups: realGroups.map((group) => group.value), + preservedSmartRoute, + preservedManualGroups, + }), + [ + autoRouteOptions, + preservedManualGroups, + preservedSmartRoute, + realGroups, + t, + ] ) - const schema = getApiKeyFormSchema(t) const form = useForm({ resolver: zodResolver(schema), - defaultValues: getApiKeyFormDefaultValues( - defaultUseAutoGroup, - defaultAutoRoute - ), + defaultValues: getApiKeyFormDefaultValues(defaultAutoRoute), }) // Load existing data when updating useEffect(() => { if (open && isUpdate && currentRow) { + setEditingLegacyRouting(false) + setPreservedSmartRoute(undefined) + setPreservedManualGroups([]) getApiKey(currentRow.id).then((result) => { if (result.success && result.data) { - form.reset(transformApiKeyToFormDefaults(result.data)) + setEditingLegacyRouting(Boolean(result.data.routing_legacy)) + const routing = result.data.routing + const routeKey = routing?.route || result.data.group + const selectableAutoRoutes = new Set( + autoRouteOptions.map((route) => route.value) + ) + const selectableManualGroups = new Set( + realGroups.map((group) => group.value) + ) + const unavailableSmartRoute = + routing?.mode === 'smart' && + routeKey && + !selectableAutoRoutes.has(routeKey) + const unavailableManualGroups = + routing?.mode === 'manual' + ? (routing.groups || []).filter( + (group) => !selectableManualGroups.has(group) + ) + : [] + setPreservedSmartRoute(unavailableSmartRoute ? routeKey : undefined) + setPreservedManualGroups(unavailableManualGroups) + const routeManualGroups = + autoRouteOptions.find((route) => route.value === routeKey) + ?.groups || defaultManualGroups + form.reset( + transformApiKeyToFormDefaults(result.data, routeManualGroups) + ) } }) } else if (open && !isUpdate) { + setEditingLegacyRouting(false) + setPreservedSmartRoute(undefined) + setPreservedManualGroups([]) form.reset( - getApiKeyFormDefaultValues( - defaultUseAutoGroup && backendHasDefaultAutoRoute, - defaultAutoRoute - ) + getApiKeyFormDefaultValues(effectiveAutoRoute, defaultManualGroups) ) } }, [ @@ -163,31 +252,31 @@ export function ApiKeysMutateDrawer({ isUpdate, currentRow, form, - defaultUseAutoGroup, - backendHasDefaultAutoRoute, - defaultAutoRoute, + effectiveAutoRoute, + defaultManualGroups, + autoRouteOptions, + realGroups, ]) - // Correct group after groups load: if the form value is not in available groups, fall back - useEffect(() => { - if (groups.length === 0) return - const currentGroup = form.getValues('group') - if (currentGroup && !groups.some((g) => g.value === currentGroup)) { - const fallback = - groups.find((g) => g.value === 'default')?.value ?? - groups[0]?.value ?? - '' - form.setValue('group', fallback) - if (isAutoRouteKey(currentGroup)) { - form.setValue('cross_group_retry', false) - } - } - }, [groups, form]) - const onSubmit = async (data: ApiKeyFormValues) => { setIsSubmitting(true) try { - const basePayload = transformFormDataToPayload(data) + const dirtyFields = form.formState.dirtyFields + const routingChanged = Boolean( + dirtyFields.routing_mode || + dirtyFields.routing_route || + dirtyFields.manual_groups || + dirtyFields.cross_group_retry + ) + const basePayload = transformFormDataToPayload(data, { + includeRouting: shouldIncludeRoutingProjection( + isUpdate, + editingLegacyRouting || + Boolean(preservedSmartRoute) || + preservedManualGroups.length > 0, + routingChanged + ), + }) if (isUpdate && currentRow) { const result = await updateApiKey({ @@ -264,8 +353,12 @@ export function ApiKeysMutateDrawer({ const quotaPlaceholder = tokensOnly ? t('Enter quota in tokens') : t('Enter quota in {{currency}}', { currency: currencyLabel }) - const selectedGroup = form.watch('group') + const routingMode = form.watch('routing_mode') + const routingRoute = form.watch('routing_route') + const manualGroups = form.watch('manual_groups') + const crossGroupRetry = form.watch('cross_group_retry') const unlimitedQuota = form.watch('unlimited_quota') + const routingRouteError = form.formState.errors.routing_route?.message return ( { onOpenChange(v) if (!v) { + setEditingLegacyRouting(false) + setPreservedSmartRoute(undefined) + setPreservedManualGroups([]) form.reset() } }} @@ -318,50 +414,70 @@ export function ApiKeysMutateDrawer({ ( + name='manual_groups' + render={() => ( - {t('Group')} - 0 + } + routesLoading={groupsLoading} + disabled={isSubmitting || groupsLoading} + onModeChange={(mode) => + form.setValue('routing_mode', mode, { + shouldDirty: true, + shouldValidate: true, + }) + } + onRouteChange={(route) => { + form.setValue('routing_route', route, { + shouldDirty: true, + shouldValidate: true, + }) + if (!form.getFieldState('manual_groups').isDirty) { + const routeGroups = + autoRouteOptions.find( + (option) => option.value === route + )?.groups || [] + form.setValue( + 'manual_groups', + routeGroups.slice(0, MAX_MANUAL_ROUTING_GROUPS), + { shouldValidate: true } + ) + } + }} + onManualGroupsChange={(groups) => + form.setValue('manual_groups', groups, { + shouldDirty: true, + shouldValidate: true, + }) + } + onRetryOnFailureChange={(enabled) => + form.setValue('cross_group_retry', enabled, { + shouldDirty: true, + }) + } /> + {routingRouteError && ( +

+ {String(routingRouteError)} +

+ )}
)} /> - {isAutoRouteKey(selectedGroup) && ( - ( - -
- - {t('Cross-group retry')} - - - {t( - 'When enabled, if channels in the current group fail, it will try channels in the next group in order.' - )} - -
- - - -
- )} - /> - )} - {isSubmitting ? t('Saving...') : t('Save changes')} diff --git a/web/default/src/features/keys/lib/api-key-form.test.ts b/web/default/src/features/keys/lib/api-key-form.test.ts new file mode 100644 index 00000000..ec1c7331 --- /dev/null +++ b/web/default/src/features/keys/lib/api-key-form.test.ts @@ -0,0 +1,212 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +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. +*/ +import type { TFunction } from 'i18next' +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { apiKeySchema, type ApiKey } from '../types' +import { + getApiKeyFormSchema, + getApiKeyFormDefaultValues, + shouldIncludeRoutingProjection, + transformApiKeyToFormDefaults, + transformFormDataToPayload, +} from './api-key-form' + +const translate = ((key: string) => key) as TFunction + +describe('API key routing form', () => { + test('defaults new keys to the configured automatic routing group', () => { + const values = getApiKeyFormDefaultValues('auto', ['base', 'default']) + const payload = transformFormDataToPayload(values) + + assert.equal(values.routing_mode, 'smart') + assert.deepEqual(values.manual_groups, ['base', 'default']) + assert.deepEqual(payload.routing, { + version: 1, + mode: 'smart', + route: 'auto', + retry_on_failure: true, + }) + }) + + test('preserves ordered manual groups in the payload', () => { + const values = { + ...getApiKeyFormDefaultValues(), + routing_mode: 'manual' as const, + manual_groups: ['vip', 'default', 'base'], + } + const payload = transformFormDataToPayload(values) + + assert.equal(payload.group, 'vip') + assert.deepEqual(payload.routing?.groups, ['vip', 'default', 'base']) + }) + + test('maps legacy real-group keys to manual routing', () => { + const apiKey = { + id: 1, + name: 'legacy', + key: 'sk-***', + status: 1, + remain_quota: 0, + used_quota: 0, + unlimited_quota: true, + expired_time: -1, + created_time: 1, + accessed_time: 1, + group: 'vip', + cross_group_retry: false, + model_limits_enabled: false, + model_limits: '', + allow_ips: '', + } satisfies ApiKey + + const values = transformApiKeyToFormDefaults(apiKey) + assert.equal(values.routing_mode, 'manual') + assert.deepEqual(values.manual_groups, ['vip']) + }) + + test('rejects unavailable smart and manual routing selections', () => { + const schema = getApiKeyFormSchema(translate, { + smartRoutes: ['auto'], + manualGroups: ['default', 'vip'], + }) + + const smart = schema.safeParse({ + ...getApiKeyFormDefaultValues('auto:removed'), + routing_route: 'auto:removed', + }) + assert.equal(smart.success, false) + + const manual = schema.safeParse({ + ...getApiKeyFormDefaultValues(), + routing_mode: 'manual', + manual_groups: ['vip', 'removed'], + }) + assert.equal(manual.success, false) + }) + + test('preserves an unavailable current smart route when routing is unchanged', () => { + const schema = getApiKeyFormSchema(translate, { + smartRoutes: ['auto'], + manualGroups: ['default'], + preservedSmartRoute: 'auto:internal', + }) + + const apiKey = { + id: 4, + name: 'hidden-route-key', + key: 'sk-***', + status: 1, + remain_quota: 0, + used_quota: 0, + unlimited_quota: true, + expired_time: -1, + created_time: 1, + accessed_time: 1, + group: 'auto:internal', + cross_group_retry: true, + routing: { + version: 1, + mode: 'smart', + route: 'auto:internal', + retry_on_failure: true, + }, + model_limits_enabled: false, + model_limits: '', + allow_ips: '', + } satisfies ApiKey + const values = { + ...transformApiKeyToFormDefaults(apiKey), + name: 'preserved-route-key', + } + + assert.equal(schema.safeParse(values).success, true) + assert.equal(shouldIncludeRoutingProjection(true, true, false), false) + }) + + test('uses the selected smart route groups when switching to manual mode', () => { + const apiKey = { + id: 2, + name: 'smart-key', + key: 'sk-***', + status: 1, + remain_quota: 0, + used_quota: 0, + unlimited_quota: true, + expired_time: -1, + created_time: 1, + accessed_time: 1, + group: 'auto:fast', + cross_group_retry: true, + routing: { + version: 1, + mode: 'smart', + route: 'auto:fast', + retry_on_failure: false, + }, + model_limits_enabled: false, + model_limits: '', + allow_ips: '', + } satisfies ApiKey + + const values = transformApiKeyToFormDefaults(apiKey, ['fast', 'default']) + assert.deepEqual(values.manual_groups, ['fast', 'default']) + assert.equal(values.cross_group_retry, false) + }) + + test('omits routing fields when an unchanged legacy key is updated', () => { + assert.equal(shouldIncludeRoutingProjection(true, true, false), false) + assert.equal(shouldIncludeRoutingProjection(true, true, true), true) + assert.equal(shouldIncludeRoutingProjection(true, false, false), true) + + const payload = transformFormDataToPayload( + getApiKeyFormDefaultValues('auto', ['default']), + { includeRouting: false } + ) + + assert.equal('routing' in payload, false) + assert.equal('group' in payload, false) + assert.equal('cross_group_retry' in payload, false) + }) + + test('ignores removed strategy fields in older routing responses', () => { + const apiKey = apiKeySchema.parse({ + id: 3, + name: 'old-policy', + key: 'sk-***', + status: 1, + remain_quota: 0, + used_quota: 0, + unlimited_quota: true, + expired_time: -1, + created_time: 1, + accessed_time: 1, + group: 'auto', + cross_group_retry: true, + routing: { + version: 1, + mode: 'smart', + route: 'auto', + strategy: 'price', + allow_request_override: true, + retry_on_failure: true, + }, + model_limits_enabled: false, + model_limits: '', + allow_ips: '', + }) + + assert.deepEqual(apiKey.routing, { + version: 1, + mode: 'smart', + route: 'auto', + retry_on_failure: true, + }) + }) +}) diff --git a/web/default/src/features/keys/lib/api-key-form.ts b/web/default/src/features/keys/lib/api-key-form.ts index 1150df35..79dcc8b1 100644 --- a/web/default/src/features/keys/lib/api-key-form.ts +++ b/web/default/src/features/keys/lib/api-key-form.ts @@ -21,13 +21,29 @@ import type { TFunction } from 'i18next' import { DEFAULT_AUTO_ROUTE_KEY, isAutoRouteKey } from '@/lib/auto-routes' import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format' import { DEFAULT_GROUP } from '../constants' -import { type ApiKeyFormData, type ApiKey } from '../types' +import { + type ApiKeyFormData, + type ApiKey, + type TokenRoutingPolicy, +} from '../types' + +export const MAX_MANUAL_ROUTING_GROUPS = 8 + +type ApiKeyRoutingAvailability = { + smartRoutes?: readonly string[] + manualGroups?: readonly string[] + preservedSmartRoute?: string + preservedManualGroups?: readonly string[] +} // ============================================================================ // Form Schema // ============================================================================ -export function getApiKeyFormSchema(t: TFunction) { +export function getApiKeyFormSchema( + t: TFunction, + availability: ApiKeyRoutingAvailability = {} +) { return z .object({ name: z.string().min(1, t('Please enter a name')), @@ -36,16 +52,16 @@ export function getApiKeyFormSchema(t: TFunction) { unlimited_quota: z.boolean(), model_limits: z.array(z.string()), allow_ips: z.string().optional(), - group: z.string().optional(), + routing_mode: z.enum(['smart', 'manual']), + routing_route: z.string(), + manual_groups: z.array(z.string()).max(MAX_MANUAL_ROUTING_GROUPS), cross_group_retry: z.boolean().optional(), tokenCount: z.number().min(1).optional(), }) .superRefine((data, ctx) => { if (data.unlimited_quota) { - return - } - - if ( + // Routing validation still applies to unlimited keys. + } else if ( data.remain_quota_dollars === undefined || data.remain_quota_dollars < 0 ) { @@ -55,6 +71,58 @@ export function getApiKeyFormSchema(t: TFunction) { message: t('Quota must be zero or greater'), }) } + + if (data.routing_mode === 'smart' && !data.routing_route.trim()) { + ctx.addIssue({ + code: 'custom', + path: ['routing_route'], + message: t('No automatic routing groups are currently available'), + }) + } + if ( + data.routing_mode === 'smart' && + availability.smartRoutes && + !availability.smartRoutes.includes(data.routing_route) && + availability.preservedSmartRoute !== data.routing_route + ) { + ctx.addIssue({ + code: 'custom', + path: ['routing_route'], + message: t('Select an available automatic routing group'), + }) + } + if (data.routing_mode === 'manual' && data.manual_groups.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['manual_groups'], + message: t('Select at least one manual routing group'), + }) + } + if ( + data.routing_mode === 'manual' && + new Set(data.manual_groups).size !== data.manual_groups.length + ) { + ctx.addIssue({ + code: 'custom', + path: ['manual_groups'], + message: t('Manual routing groups must not contain duplicates'), + }) + } + if ( + data.routing_mode === 'manual' && + availability.manualGroups && + data.manual_groups.some( + (group) => + !availability.manualGroups?.includes(group) && + !availability.preservedManualGroups?.includes(group) + ) + ) { + ctx.addIssue({ + code: 'custom', + path: ['manual_groups'], + message: t('Remove unavailable manual routing groups before saving'), + }) + } }) } @@ -71,22 +139,56 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = { unlimited_quota: true, model_limits: [], allow_ips: '', - group: DEFAULT_GROUP, + routing_mode: 'smart', + routing_route: DEFAULT_AUTO_ROUTE_KEY, + manual_groups: [DEFAULT_GROUP], cross_group_retry: true, tokenCount: 1, } export function getApiKeyFormDefaultValues( - defaultUseAutoGroup: boolean, - defaultAutoRoute: string = DEFAULT_AUTO_ROUTE_KEY + defaultAutoRoute: string = DEFAULT_AUTO_ROUTE_KEY, + defaultManualGroups: string[] = [DEFAULT_GROUP] ): ApiKeyFormValues { return { ...API_KEY_FORM_DEFAULT_VALUES, - group: defaultUseAutoGroup ? defaultAutoRoute : DEFAULT_GROUP, - cross_group_retry: defaultUseAutoGroup, + routing_route: defaultAutoRoute, + manual_groups: + defaultManualGroups.length > 0 + ? defaultManualGroups.slice(0, MAX_MANUAL_ROUTING_GROUPS) + : [DEFAULT_GROUP], + } +} + +function buildRoutingPolicy(data: ApiKeyFormValues): TokenRoutingPolicy { + if (data.routing_mode === 'manual') { + return { + version: 1, + mode: 'manual', + groups: data.manual_groups, + retry_on_failure: !!data.cross_group_retry, + } + } + return { + version: 1, + mode: 'smart', + route: data.routing_route || DEFAULT_AUTO_ROUTE_KEY, + retry_on_failure: !!data.cross_group_retry, } } +type TransformFormDataOptions = { + includeRouting?: boolean +} + +export function shouldIncludeRoutingProjection( + isUpdate: boolean, + routingLegacy: boolean, + routingChanged: boolean +): boolean { + return !(isUpdate && routingLegacy && !routingChanged) +} + // ============================================================================ // Form Data Transformation // ============================================================================ @@ -95,9 +197,15 @@ export function getApiKeyFormDefaultValues( * Transform form data to API payload */ export function transformFormDataToPayload( - data: ApiKeyFormValues + data: ApiKeyFormValues, + options: TransformFormDataOptions = {} ): ApiKeyFormData { - return { + const routing = buildRoutingPolicy(data) + const projectedGroup = + routing.mode === 'manual' + ? routing.groups?.[0] || '' + : routing.route || DEFAULT_AUTO_ROUTE_KEY + const payload: ApiKeyFormData = { name: data.name, remain_quota: data.unlimited_quota ? 0 @@ -109,10 +217,13 @@ export function transformFormDataToPayload( model_limits_enabled: data.model_limits.length > 0, model_limits: data.model_limits.join(','), allow_ips: data.allow_ips || '', - group: data.group || '', - cross_group_retry: isAutoRouteKey(data.group) - ? !!data.cross_group_retry - : false, + } + if (options.includeRouting === false) return payload + return { + ...payload, + group: projectedGroup, + cross_group_retry: !!data.cross_group_retry, + routing, } } @@ -120,8 +231,20 @@ export function transformFormDataToPayload( * Transform API key data to form defaults */ export function transformApiKeyToFormDefaults( - apiKey: ApiKey + apiKey: ApiKey, + smartModeManualGroups: string[] = [DEFAULT_GROUP] ): ApiKeyFormValues { + const routing = apiKey.routing + const legacySmart = !routing && isAutoRouteKey(apiKey.group) + const mode = routing?.mode ?? (legacySmart ? 'smart' : 'manual') + const manualGroups = + mode === 'manual' + ? routing?.groups?.length + ? routing.groups + : [apiKey.group || DEFAULT_GROUP] + : smartModeManualGroups.length > 0 + ? smartModeManualGroups.slice(0, MAX_MANUAL_ROUTING_GROUPS) + : [DEFAULT_GROUP] return { name: apiKey.name, remain_quota_dollars: apiKey.unlimited_quota @@ -136,8 +259,13 @@ export function transformApiKeyToFormDefaults( ? apiKey.model_limits.split(',').filter(Boolean) : [], allow_ips: apiKey.allow_ips || '', - group: apiKey.group || DEFAULT_GROUP, - cross_group_retry: !!apiKey.cross_group_retry, + routing_mode: mode, + routing_route: + mode === 'smart' + ? routing?.route || apiKey.group || DEFAULT_AUTO_ROUTE_KEY + : DEFAULT_AUTO_ROUTE_KEY, + manual_groups: manualGroups, + cross_group_retry: routing?.retry_on_failure ?? !!apiKey.cross_group_retry, tokenCount: 1, } } diff --git a/web/default/src/features/keys/lib/index.ts b/web/default/src/features/keys/lib/index.ts index fc6c01ba..ec8ea14e 100644 --- a/web/default/src/features/keys/lib/index.ts +++ b/web/default/src/features/keys/lib/index.ts @@ -21,9 +21,11 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API // ============================================================================ export { getApiKeyFormSchema, + MAX_MANUAL_ROUTING_GROUPS, type ApiKeyFormValues, API_KEY_FORM_DEFAULT_VALUES, getApiKeyFormDefaultValues, + shouldIncludeRoutingProjection, transformFormDataToPayload, transformApiKeyToFormDefaults, } from './api-key-form' diff --git a/web/default/src/features/keys/types.ts b/web/default/src/features/keys/types.ts index a4062a72..a471731c 100644 --- a/web/default/src/features/keys/types.ts +++ b/web/default/src/features/keys/types.ts @@ -18,6 +18,19 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { z } from 'zod' +export const tokenRoutingModeSchema = z.enum(['smart', 'manual']) + +export const tokenRoutingPolicySchema = z.object({ + version: z.number().default(1), + mode: tokenRoutingModeSchema, + route: z.string().optional(), + groups: z.array(z.string()).optional(), + retry_on_failure: z.boolean().default(false), +}) + +export type TokenRoutingMode = z.infer +export type TokenRoutingPolicy = z.infer + // ============================================================================ // API Key Schema & Types // ============================================================================ @@ -42,6 +55,8 @@ export const apiKeySchema = z.object({ }, z.boolean()) .optional() .default(false), + routing: tokenRoutingPolicySchema.nullish(), + routing_legacy: z.boolean().optional(), model_limits_enabled: z.boolean(), model_limits: z.string().nullish().default(''), allow_ips: z.string().nullish().default(''), @@ -90,8 +105,9 @@ export interface ApiKeyFormData { model_limits_enabled: boolean model_limits: string allow_ips: string - group: string - cross_group_retry: boolean + group?: string + cross_group_retry?: boolean + routing?: TokenRoutingPolicy } // ============================================================================ diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 8d3b7d36..0269a8f0 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -24,6 +24,7 @@ "{{category}} Models": "{{category}} Models", "{{completed}}/{{total}} completed": "{{completed}}/{{total}} completed", "{{count}} {{entity}} selected. Bulk actions toolbar is available.": "{{count}} {{entity}} selected. Bulk actions toolbar is available.", + "{{count}} / {{max}} groups selected": "{{count}} / {{max}} groups selected", "{{count}} blocking issue(s)": "{{count}} blocking issue(s)", "{{count}} channel(s) deleted": "{{count}} channel(s) deleted", "{{count}} channel(s) disabled": "{{count}} channel(s) disabled", @@ -587,6 +588,7 @@ "Auto-fill when one field exists and another is missing": "Auto-fill when one field exists and another is missing", "Auto-retry status codes": "Auto-retry status codes", "Automatic mode upgrades with STARTTLS when available; STARTTLS mode requires server support": "Automatic mode upgrades with STARTTLS when available; STARTTLS mode requires server support", + "Automatic routing group": "Automatic routing group", "Automatic STARTTLS": "Automatic STARTTLS", "Automatically disable channel on repeated failures": "Automatically disable channel on repeated failures", "Automatically disable channels exceeding this response time": "Automatically disable channels exceeding this response time", @@ -870,6 +872,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "Chinese", + "Choose a system-defined group chain. Requests follow its configured order.": "Choose a system-defined group chain. Requests follow its configured order.", "Choose a username": "Choose a username", "Choose an amount and payment method": "Choose an amount and payment method", "Choose between default expanded, compact icon-only, or full layout mode": "Choose between default expanded, compact icon-only, or full layout mode", @@ -1445,6 +1448,7 @@ "Disable": "Disable", "Disable 2FA": "Disable 2FA", "Disable All": "Disable All", + "Disable and select groups": "Disable and select groups", "Disable on failure": "Disable on failure", "Disable selected channels": "Disable selected channels", "Disable selected models": "Disable selected models", @@ -1513,6 +1517,7 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.", "Download": "Download", + "Drag {{group}} to reorder": "Drag {{group}} to reorder", "Draw": "Draw", "Drawing": "Drawing", "Drawing logs": "Drawing logs", @@ -1656,6 +1661,7 @@ "Enable Request Passthrough": "Enable Request Passthrough", "Enable selected channels": "Enable selected channels", "Enable selected models": "Enable selected models", + "Enable automatic routing": "Enable automatic routing", "Enable SSL/TLS": "Enable SSL/TLS", "Enable SSRF Protection": "Enable SSRF Protection", "Enable streaming mode for the test request.": "Enable streaming mode for the test request.", @@ -2330,6 +2336,8 @@ "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "If default auto group is enabled, newly created tokens start with auto instead of an empty group.", "If default auto group is enabled, newly created tokens start with the configured default auto route.": "If default auto group is enabled, newly created tokens start with the configured default auto route.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.", + "If the current group fails, try the next group in the configured automatic route order.": "If the current group fails, try the next group in the configured automatic route order.", + "If the current group fails, try the next group in the manual order.": "If the current group fails, try the next group in the manual order.", "If this keeps happening, please report it on GitHub Issues.": "If this keeps happening, please report it on GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.", "Ignore": "Ignore", @@ -2637,7 +2645,10 @@ "Manage Vendors": "Manage Vendors", "Manage your LinuxDO OAuth app": "Manage your LinuxDO OAuth app", "Manage your security settings and account access": "Manage your security settings and account access", + "Manual {{count}} groups: {{groups}}": "Manual {{count}} groups: {{groups}}", "Manual Disabled": "Manual Disabled", + "Manual group order": "Manual group order", + "Manual routing groups must not contain duplicates": "Manual routing groups must not contain duplicates", "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).", "Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.": "Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.", "Map request model names to actual provider model names (JSON format)": "Map request model names to actual provider model names (JSON format)", @@ -2846,6 +2857,8 @@ "More...": "More...", "Most-used models in the selected period and category": "Most-used models in the selected period and category", "Move": "Move", + "Move {{group}} down": "Move {{group}} down", + "Move {{group}} up": "Move {{group}} up", "Move a request header": "Move a request header", "Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance", "Move down": "Move down", @@ -2947,6 +2960,7 @@ "No app usage data available for this model.": "No app usage data available for this model.", "No apps match the selected filters": "No apps match the selected filters", "No Auth": "No Auth", + "No automatic routing groups": "No automatic routing groups", "No available models": "No available models", "No available Web chat links": "No available Web chat links", "No backup": "No backup", @@ -2988,6 +3002,7 @@ "No files match the accepted types.": "No files match the accepted types.", "No group found.": "No group found.", "No group-based rate limits configured. Click \"Add group\" to get started.": "No group-based rate limits configured. Click \"Add group\" to get started.", + "No groups found": "No groups found", "No groups match your search": "No groups match your search", "No groups yet. Add a group to get started.": "No groups yet. Add a group to get started.", "No header overrides configured.": "No header overrides configured.", @@ -3066,6 +3081,8 @@ "No rules yet": "No rules yet", "No rules yet. Add a group below to get started.": "No rules yet. Add a group below to get started.", "No separate media pricing configured.": "No separate media pricing configured.", + "No automatic routing groups are currently available": "No automatic routing groups are currently available", + "No automatic routing groups are currently available. Select manual routing or contact an administrator.": "No automatic routing groups are currently available. Select manual routing or contact an administrator.", "No status code mappings configured.": "No status code mappings configured.", "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.", "No subscription plans yet": "No subscription plans yet", @@ -3810,6 +3827,7 @@ "Remaining:": "Remaining:", "Remark": "Remark", "Remove": "Remove", + "Remove {{group}}": "Remove {{group}}", "Remove {{value}}": "Remove {{value}}", "Remove ${{amount}}": "Remove ${{amount}}", "Remove all log entries created before the selected timestamp.": "Remove all log entries created before the selected timestamp.", @@ -3828,6 +3846,7 @@ "Remove tag": "Remove tag", "Remove the target field": "Remove the target field", "Remove tier": "Remove tier", + "Remove unavailable manual routing groups before saving": "Remove unavailable manual routing groups before saving", "Removed": "Removed", "Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}": "Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}", "Removed Models ({{count}})": "Removed Models ({{count}})", @@ -3881,6 +3900,7 @@ "Requests are mapped to model aliases, provider channels, retries, failover rules, task polling flows, response handling, and settlement events.": "Requests are mapped to model aliases, provider channels, retries, failover rules, task polling flows, response handling, and settlement events.", "Requests per minute": "Requests per minute", "requests served": "requests served", + "Requests try groups in the order shown below.": "Requests try groups in the order shown below.", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "Requests will be forwarded to this worker. Trailing slashes are removed automatically.", "Requests:": "Requests:", "Require email verification for new accounts": "Require email verification for new accounts", @@ -4011,6 +4031,7 @@ "Routing & Overrides": "Routing & Overrides", "Routing and reliability governance": "Routing and reliability governance", "Routing fabric": "Routing fabric", + "Routing group unavailable": "Routing group unavailable", "Routing matrix": "Routing matrix", "Routing policy": "Routing policy", "Routing resilience": "Routing resilience", @@ -4116,6 +4137,7 @@ "Search deployments...": "Search deployments...", "Search feature in development": "Search feature in development", "Search group names...": "Search group names...", + "Search groups": "Search groups", "Search groups...": "Search groups...", "Search missing models": "Search missing models", "Search model name, provider, endpoint, or tag...": "Search model name, provider, endpoint, or tag...", @@ -4143,7 +4165,6 @@ "Security boundary": "Security boundary", "Security Check": "Security Check", "Security verification": "Security verification", - "Verify your identity before permanently deleting your account.": "Verify your identity before permanently deleting your account.", "Seed": "Seed", "Seedance": "Seedance", "Select": "Select", @@ -4166,9 +4187,11 @@ "Select all (filtered)": "Select all (filtered)", "Select all models": "Select all models", "Select All Visible": "Select All Visible", + "Select an available automatic routing group": "Select an available automatic routing group", "Select an operation mode and enter the amount": "Select an operation mode and enter the amount", "Select announcement type": "Select announcement type", "Select at least one field to overwrite.": "Select at least one field to overwrite.", + "Select at least one manual routing group": "Select at least one manual routing group", "Select at least one target model": "Select at least one target model", "Select body font": "Select body font", "Select border radius": "Select border radius", @@ -4184,6 +4207,7 @@ "Select end time": "Select end time", "Select from presets or type custom identifier.": "Select from presets or type custom identifier.", "Select granularity": "Select granularity", + "Select groups": "Select groups", "Select groups (leave empty to keep current)": "Select groups (leave empty to keep current)", "Select interface density": "Select interface density", "Select items...": "Select items...", @@ -4341,6 +4365,8 @@ "Slug is required": "Slug is required", "Slug must be less than 100 characters": "Slug must be less than 100 characters", "Smallest USD amount users can recharge (Epay)": "Smallest USD amount users can recharge (Epay)", + "Automatic routing is disabled. The manual group order is used.": "Automatic routing is disabled. The manual group order is used.", + "Automatic routing is enabled. The system follows the selected automatic route group order.": "Automatic routing is enabled. The system follows the selected automatic route group order.", "SMTP Email": "SMTP Email", "SMTP encryption": "SMTP encryption", "SMTP Host": "SMTP Host", @@ -4519,6 +4545,7 @@ "System Prompt": "System Prompt", "System Prompt Concatenation": "System Prompt Concatenation", "System Prompt Override": "System Prompt Override", + "System route: {{route}}": "System route: {{route}}", "System settings": "System settings", "System Settings": "System Settings", "System setup wizard": "System setup wizard", @@ -4619,6 +4646,8 @@ "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations", "The redesigned homepage follows the same lifecycle that MAX API manages in production: connect, govern, route, settle, and audit.": "The redesigned homepage follows the same lifecycle that MAX API manages in production: connect, govern, route, settle, and audit.", "The requested chat preset does not exist or has been removed.": "The requested chat preset does not exist or has been removed.", + "The saved routing group {{route}} is no longer available. Select another group before saving.": "The saved routing group {{route}} is no longer available. Select another group before saving.", + "The saved routing group {{route}} is not available for new selections. It will be preserved unless routing is changed.": "The saved routing group {{route}} is not available for new selections. It will be preserved unless routing is changed.", "The setup wizard will use this database during initialization.": "The setup wizard will use this database during initialization.", "The site is not available at the moment.": "The site is not available at the moment.", "The slug is appended to the URL:": "The slug is appended to the URL:", @@ -4887,6 +4916,7 @@ "Unable to read clipboard": "Unable to read clipboard", "Unauthorized": "Unauthorized", "Unauthorized Access": "Unauthorized Access", + "Unavailable": "Unavailable", "Unbind": "Unbind", "Unbind failed": "Unbind failed", "Unbound {{provider}}": "Unbound {{provider}}", @@ -5114,6 +5144,7 @@ "Verify routing with Playground or your client": "Verify routing with Playground or your client", "Verify Setup": "Verify Setup", "Verify your database connection": "Verify your database connection", + "Verify your identity before permanently deleting your account.": "Verify your identity before permanently deleting your account.", "Verifying credentials and pulling stores from your Pancake account...": "Verifying credentials and pulling stores from your Pancake account...", "Version Overrides": "Version Overrides", "Vertex AI": "Vertex AI", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 38b37915..663f5cc8 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -24,6 +24,7 @@ "{{category}} Models": "Modèles {{category}}", "{{completed}}/{{total}} completed": "{{completed}}/{{total}} terminé(s)", "{{count}} {{entity}} selected. Bulk actions toolbar is available.": "{{count}} {{entity}} sélectionné(s). La barre d’actions groupées est disponible.", + "{{count}} / {{max}} groups selected": "{{count}} / {{max}} groupes sélectionnés", "{{count}} blocking issue(s)": "{{count}} problème(s) bloquant(s)", "{{count}} channel(s) deleted": "{{count}} canal(canaux) supprimé(s)", "{{count}} channel(s) disabled": "{{count}} canal(canaux) désactivé(s)", @@ -587,6 +588,7 @@ "Auto-fill when one field exists and another is missing": "Remplissage automatique si un champ existe et l'autre est manquant", "Auto-retry status codes": "Codes de statut de nouvelle tentative auto", "Automatic mode upgrades with STARTTLS when available; STARTTLS mode requires server support": "Le mode automatique passe à STARTTLS lorsqu'il est disponible ; le mode STARTTLS exige sa prise en charge par le serveur", + "Automatic routing group": "Groupe de routage automatique", "Automatic STARTTLS": "STARTTLS automatique", "Automatically disable channel on repeated failures": "Désactiver automatiquement le canal en cas d'échecs répétés", "Automatically disable channels exceeding this response time": "Désactiver automatiquement les canaux dépassant ce temps de réponse", @@ -870,6 +872,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "Chinois", + "Choose a system-defined group chain. Requests follow its configured order.": "Choisissez une chaîne de groupes définie par le système. Les requêtes suivent l'ordre configuré.", "Choose a username": "Choisir un nom d'utilisateur", "Choose an amount and payment method": "Choisir un montant et un mode de paiement", "Choose between default expanded, compact icon-only, or full layout mode": "Choisissez entre le mode d'affichage étendu par défaut, compact (icône uniquement) ou complet", @@ -1445,6 +1448,7 @@ "Disable": "Désactiver", "Disable 2FA": "Désactiver la 2FA", "Disable All": "Désactiver tout", + "Disable and select groups": "Désactiver et sélectionner les groupes", "Disable on failure": "Désactiver en cas d'échec", "Disable selected channels": "Désactiver les canaux sélectionnés", "Disable selected models": "Désactiver les modèles sélectionnés", @@ -1513,6 +1517,7 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Vérifiez la configuration ci-dessous. Votre système sera verrouillé jusqu'à ce que l'initialisation soit terminée.", "Download": "Télécharger", + "Drag {{group}} to reorder": "Faites glisser {{group}} pour modifier l’ordre", "Draw": "Dessin", "Drawing": "Dessin", "Drawing logs": "Journaux de dessin", @@ -1656,6 +1661,7 @@ "Enable Request Passthrough": "Activer le Passthrough de requêtes", "Enable selected channels": "Activer les canaux sélectionnés", "Enable selected models": "Activer les modèles sélectionnés", + "Enable automatic routing": "Activer le routage automatique", "Enable SSL/TLS": "Activer SSL/TLS", "Enable SSRF Protection": "Activer la protection SSRF", "Enable streaming mode for the test request.": "Activer le mode streaming pour la requête de test.", @@ -2330,6 +2336,8 @@ "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Si le groupe auto par défaut est activé, les nouveaux jetons commencent avec auto au lieu d’un groupe vide.", "If default auto group is enabled, newly created tokens start with the configured default auto route.": "Si le groupe auto par défaut est activé, les nouveaux jetons démarrent avec la route auto par défaut configurée.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Si le canal affinitaire échoue et qu'une nouvelle tentative réussit sur un autre canal, mettre à jour l'affinité vers le canal ayant réussi.", + "If the current group fails, try the next group in the configured automatic route order.": "Si le groupe actuel échoue, essayez le groupe suivant dans l'ordre de routage automatique configuré.", + "If the current group fails, try the next group in the manual order.": "Si le groupe actuel échoue, essayez le suivant selon l'ordre manuel.", "If this keeps happening, please report it on GitHub Issues.": "Si cela continue, veuillez le signaler sur GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Si vous fournissez des services d’IA générative au public en Chine continentale, vous remplirez les obligations légales applicables, notamment le dépôt, l’évaluation de sécurité, la sécurité du contenu, le traitement des plaintes, l’étiquetage du contenu généré, la conservation des journaux et la protection des informations personnelles.", "Ignore": "Ignorer", @@ -2637,7 +2645,10 @@ "Manage Vendors": "Gérer les fournisseurs", "Manage your LinuxDO OAuth app": "Gérer votre application OAuth LinuxDO", "Manage your security settings and account access": "Gérer vos paramètres de sécurité et l'accès à votre compte", + "Manual {{count}} groups: {{groups}}": "{{count}} groupes manuels : {{groups}}", "Manual Disabled": "Désactivé manuellement", + "Manual group order": "Ordre manuel des groupes", + "Manual routing groups must not contain duplicates": "Les groupes de routage manuel ne doivent pas contenir de doublons", "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "Mapper les champs de la réponse des informations utilisateur vers les attributs utilisateur locaux. Supporte les chemins imbriqués (par exemple ocs.data.id).", "Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.": "Mapper les identifiants de modèle aux versions de l'API Gemini. Une entrée `default` s'applique lorsqu'aucune correspondance spécifique n'est trouvée.", "Map request model names to actual provider model names (JSON format)": "Mapper les noms de modèles de requête aux noms réels de modèles du fournisseur (format JSON)", @@ -2846,6 +2857,8 @@ "More...": "Plus...", "Most-used models in the selected period and category": "Modèles les plus utilisés dans la période et catégorie choisies", "Move": "Déplacer", + "Move {{group}} down": "Déplacer {{group}} vers le bas", + "Move {{group}} up": "Déplacer {{group}} vers le haut", "Move a request header": "Déplacer un en-tête de requête", "Move affiliate rewards to your main balance": "Transférer les récompenses d'affiliation vers votre solde principal", "Move down": "Déplacer vers le bas", @@ -2947,6 +2960,7 @@ "No app usage data available for this model.": "Aucune donnée d'utilisation d'application n'est disponible pour ce modèle.", "No apps match the selected filters": "Aucune application ne correspond aux filtres", "No Auth": "Sans auth", + "No automatic routing groups": "Aucun groupe de routage automatique", "No available models": "Aucun modèle disponible", "No available Web chat links": "Aucun lien de chat Web disponible", "No backup": "Pas de sauvegarde", @@ -2988,6 +3002,7 @@ "No files match the accepted types.": "Aucun fichier ne correspond aux types acceptés.", "No group found.": "Aucun groupe trouvé.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Aucune limite de taux basée sur les groupes configurée. Cliquez sur \"Ajouter un groupe\" pour commencer.", + "No groups found": "Aucun groupe trouvé", "No groups match your search": "Aucun groupe ne correspond à votre recherche", "No groups yet. Add a group to get started.": "Aucun groupe pour le moment. Ajoutez un groupe pour commencer.", "No header overrides configured.": "Aucune surcharge d'en-têtes configurée.", @@ -3066,6 +3081,8 @@ "No rules yet": "Aucune règle", "No rules yet. Add a group below to get started.": "Aucune règle pour le moment. Ajoutez un groupe ci-dessous pour commencer.", "No separate media pricing configured.": "Aucune tarification multimédia séparée n’est configurée.", + "No automatic routing groups are currently available": "Aucun groupe de routage automatique n'est actuellement disponible", + "No automatic routing groups are currently available. Select manual routing or contact an administrator.": "Aucun groupe de routage automatique n'est actuellement disponible. Sélectionnez le routage manuel ou contactez un administrateur.", "No status code mappings configured.": "Aucun mappage de codes de statut configuré.", "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "Ce marchand n’a pas encore de boutique. Définissez une URL de retour puis cliquez sur Créer pour générer votre première paire.", "No subscription plans yet": "Aucun plan d'abonnement pour le moment", @@ -3810,6 +3827,7 @@ "Remaining:": "Restant :", "Remark": "Remarque", "Remove": "Supprimer", + "Remove {{group}}": "Supprimer {{group}}", "Remove {{value}}": "Retirer {{value}}", "Remove ${{amount}}": "Supprimer ${{amount}}", "Remove all log entries created before the selected timestamp.": "Supprimer toutes les entrées de journal créées avant l'horodatage sélectionné.", @@ -3828,6 +3846,7 @@ "Remove tag": "Supprimer l’étiquette", "Remove the target field": "Supprimer le champ cible", "Remove tier": "Supprimer le palier", + "Remove unavailable manual routing groups before saving": "Supprimez les groupes de routage manuel indisponibles avant d’enregistrer", "Removed": "Supprimé", "Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}": "{{removed}} clé(s) en double supprimée(s). Avant : {{before}}, Après : {{after}}", "Removed Models ({{count}})": "Modèles supprimés ({{count}})", @@ -3881,6 +3900,7 @@ "Requests are mapped to model aliases, provider channels, retries, failover rules, task polling flows, response handling, and settlement events.": "Les requêtes sont mappées vers des alias de modèles, canaux fournisseurs, règles de reprise, bascule, flux de polling de tâches, traitement de réponse et événements de règlement.", "Requests per minute": "Requêtes par minute", "requests served": "requêtes traitées", + "Requests try groups in the order shown below.": "Les requêtes essaient les groupes dans l'ordre affiché ci-dessous.", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "Les requêtes seront transmises à ce worker. Les barres obliques finales sont automatiquement supprimées.", "Requests:": "Requêtes :", "Require email verification for new accounts": "Exiger la vérification de l'e-mail pour les nouveaux comptes", @@ -4011,6 +4031,7 @@ "Routing & Overrides": "Routage et surcharges", "Routing and reliability governance": "Gouvernance du routage et de la fiabilité", "Routing fabric": "Maillage de routage", + "Routing group unavailable": "Groupe de routage indisponible", "Routing matrix": "Matrice de routage", "Routing policy": "Routing policy", "Routing resilience": "Résilience du routage", @@ -4116,6 +4137,7 @@ "Search deployments...": "Rechercher des déploiements...", "Search feature in development": "Fonctionnalité de recherche en développement", "Search group names...": "Rechercher des noms de groupe...", + "Search groups": "Rechercher des groupes", "Search groups...": "Rechercher des groupes...", "Search missing models": "Rechercher les modèles manquants", "Search model name, provider, endpoint, or tag...": "Rechercher un nom de modèle, fournisseur, endpoint ou tag...", @@ -4143,7 +4165,6 @@ "Security boundary": "Frontière de sécurité", "Security Check": "Vérification de sécurité", "Security verification": "Vérification de sécurité", - "Verify your identity before permanently deleting your account.": "Vérifiez votre identité avant de supprimer définitivement votre compte.", "Seed": "Graine", "Seedance": "Seedance", "Select": "Sélectionner", @@ -4166,9 +4187,11 @@ "Select all (filtered)": "Tout sélectionner (filtré)", "Select all models": "Sélectionner tous les modèles", "Select All Visible": "Sélectionner tout ce qui est visible", + "Select an available automatic routing group": "Sélectionnez un groupe de routage automatique disponible", "Select an operation mode and enter the amount": "Sélectionnez un mode d'opération et entrez le montant", "Select announcement type": "Sélectionner le type d'annonce", "Select at least one field to overwrite.": "Sélectionnez au moins un champ à écraser.", + "Select at least one manual routing group": "Sélectionnez au moins un groupe de routage manuel", "Select at least one target model": "Sélectionnez au moins un modèle cible", "Select body font": "Sélectionner la police du corps de texte", "Select border radius": "Sélectionner le rayon de bordure", @@ -4184,6 +4207,7 @@ "Select end time": "Sélectionner l'heure de fin", "Select from presets or type custom identifier.": "Sélectionner parmi les préréglages ou saisir un identifiant personnalisé.", "Select granularity": "Sélectionner la granularité", + "Select groups": "Sélectionner des groupes", "Select groups (leave empty to keep current)": "Sélectionner les groupes (laisser vide pour conserver les groupes actuels)", "Select interface density": "Sélectionner la densité de l'interface", "Select items...": "Sélectionner des éléments...", @@ -4341,6 +4365,8 @@ "Slug is required": "Le slug est requis", "Slug must be less than 100 characters": "Le slug doit contenir moins de 100 caractères", "Smallest USD amount users can recharge (Epay)": "Montant minimum en USD que les utilisateurs peuvent recharger (Epay)", + "Automatic routing is disabled. The manual group order is used.": "Le routage automatique est désactivé. L'ordre manuel des groupes est utilisé.", + "Automatic routing is enabled. The system follows the selected automatic route group order.": "Le routage automatique est activé. Le système suit l'ordre du groupe de routage automatique sélectionné.", "SMTP Email": "E-mail SMTP", "SMTP encryption": "Chiffrement SMTP", "SMTP Host": "Hôte SMTP", @@ -4519,6 +4545,7 @@ "System Prompt": "Invite système", "System Prompt Concatenation": "Concaténation des invites système", "System Prompt Override": "Remplacement du prompt système", + "System route: {{route}}": "Route système : {{route}}", "System settings": "Paramètres système", "System Settings": "Paramètres du système", "System setup wizard": "Assistant de configuration du système", @@ -4619,6 +4646,8 @@ "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "L'URL publique de votre serveur, utilisée pour les rappels OAuth, les webhooks et autres intégrations externes", "The redesigned homepage follows the same lifecycle that MAX API manages in production: connect, govern, route, settle, and audit.": "La page d'accueil redesignée suit le même cycle de vie que MAX API gère en production : connecter, gouverner, router, régler et auditer.", "The requested chat preset does not exist or has been removed.": "Le préréglage de discussion demandé n'existe pas ou a été supprimé.", + "The saved routing group {{route}} is no longer available. Select another group before saving.": "Le groupe de routage enregistré {{route}} n'est plus disponible. Sélectionnez un autre groupe avant d'enregistrer.", + "The saved routing group {{route}} is not available for new selections. It will be preserved unless routing is changed.": "Le groupe de routage enregistré {{route}} n'est pas disponible pour de nouvelles sélections. Il sera conservé tant que le routage n'est pas modifié.", "The setup wizard will use this database during initialization.": "L'assistant de configuration utilisera cette base de données lors de l'initialisation.", "The site is not available at the moment.": "Le site n'est pas disponible pour le moment.", "The slug is appended to the URL:": "Le slug est ajouté à l'URL :", @@ -4887,6 +4916,7 @@ "Unable to read clipboard": "Impossible de lire le presse-papiers", "Unauthorized": "Non autorisé", "Unauthorized Access": "Accès non autorisé", + "Unavailable": "Indisponible", "Unbind": "Dissocier", "Unbind failed": "Échec de la dissociation", "Unbound {{provider}}": "{{provider}} dissocié", @@ -5114,6 +5144,7 @@ "Verify routing with Playground or your client": "Vérifiez le routage avec Playground ou votre client", "Verify Setup": "Vérifier la configuration", "Verify your database connection": "Vérifiez votre connexion à la base de données", + "Verify your identity before permanently deleting your account.": "Vérifiez votre identité avant de supprimer définitivement votre compte.", "Verifying credentials and pulling stores from your Pancake account...": "Vérification des identifiants et récupération des boutiques depuis votre compte Pancake...", "Version Overrides": "Remplacements de version", "Vertex AI": "Vertex AI", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index db54eac4..8e6a4124 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -24,6 +24,7 @@ "{{category}} Models": "{{category}} モデル", "{{completed}}/{{total}} completed": "{{completed}}/{{total}} 完了", "{{count}} {{entity}} selected. Bulk actions toolbar is available.": "{{entity}}を{{count}}件選択しました。一括操作ツールバーを利用できます。", + "{{count}} / {{max}} groups selected": "{{count}} / {{max}} グループを選択済み", "{{count}} blocking issue(s)": "ブロック中の問題 {{count}} 件", "{{count}} channel(s) deleted": "{{count}} 個のチャネルを削除しました", "{{count}} channel(s) disabled": "{{count}} 個のチャネルを無効にしました", @@ -587,6 +588,7 @@ "Auto-fill when one field exists and another is missing": "一方のフィールドがあり他方が欠けている場合に自動補完", "Auto-retry status codes": "自動リトライするステータスコード", "Automatic mode upgrades with STARTTLS when available; STARTTLS mode requires server support": "利用可能な場合は STARTTLS に自動アップグレードします。STARTTLS モードではサーバー対応が必須です", + "Automatic routing group": "自動ルーティンググループ", "Automatic STARTTLS": "自動 STARTTLS", "Automatically disable channel on repeated failures": "繰り返しの失敗でチャンネルを自動的に無効にする", "Automatically disable channels exceeding this response time": "この応答時間を超えるチャネルを自動的に無効にする", @@ -870,6 +872,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "中国語", + "Choose a system-defined group chain. Requests follow its configured order.": "システム定義のグループチェーンを選択します。リクエストは設定された順序に従います。", "Choose a username": "ユーザー名を選択", "Choose an amount and payment method": "金額と支払い方法を選択してください", "Choose between default expanded, compact icon-only, or full layout mode": "デフォルトの展開表示、コンパクトなアイコンのみ、またはフルレイアウトモードから選択します", @@ -1445,6 +1448,7 @@ "Disable": "無効にする", "Disable 2FA": "2FAを無効にする", "Disable All": "すべて無効にする", + "Disable and select groups": "無効にしてグループを選択", "Disable on failure": "失敗時に無効にする", "Disable selected channels": "選択したチャネルを無効にする", "Disable selected models": "選択したモデルを無効にする", @@ -1513,6 +1517,7 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "下記の設定を再確認してください。初期化が完了するまでシステムはロックされます。", "Download": "ダウンロード", + "Drag {{group}} to reorder": "{{group}} をドラッグして並べ替え", "Draw": "描画", "Drawing": "画像生成", "Drawing logs": "描画ログ", @@ -1656,6 +1661,7 @@ "Enable Request Passthrough": "リクエストパススルーを有効にする", "Enable selected channels": "選択したチャネルを有効にする", "Enable selected models": "選択したモデルを有効にする", + "Enable automatic routing": "自動ルーティングを有効化", "Enable SSL/TLS": "SSL/TLSを有効にする", "Enable SSRF Protection": "SSRF保護を有効にする", "Enable streaming mode for the test request.": "テストリクエストのストリーミングモードを有効にします。", @@ -2330,6 +2336,8 @@ "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "デフォルト auto グループを有効にすると、新規トークンは空グループではなく auto で開始します。", "If default auto group is enabled, newly created tokens start with the configured default auto route.": "デフォルト自動グループが有効な場合、新しく作成されるトークンは設定済みのデフォルト自動ルートから開始します。", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "アフィニティチャネルが失敗し、別のチャネルでリトライが成功した場合、アフィニティを成功したチャネルに更新します。", + "If the current group fails, try the next group in the configured automatic route order.": "現在のグループが失敗した場合、設定された自動ルーティング順序の次のグループを試します。", + "If the current group fails, try the next group in the manual order.": "現在のグループが失敗した場合、手動順序の次のグループを試します。", "If this keeps happening, please report it on GitHub Issues.": "この問題が続く場合は、GitHub Issues で報告してください。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "中国本土で一般向けに生成 AI サービスを提供する場合、届出、セキュリティ評価、コンテンツ安全、苦情対応、生成コンテンツのラベル表示、ログ保存、個人情報保護などの法的義務を履行します。", "Ignore": "無視", @@ -2637,7 +2645,10 @@ "Manage Vendors": "ベンダーの管理", "Manage your LinuxDO OAuth app": "LinuxDO OAuth アプリを管理", "Manage your security settings and account access": "セキュリティ設定とアカウントアクセスを管理する", + "Manual {{count}} groups: {{groups}}": "手動 {{count}} グループ:{{groups}}", "Manual Disabled": "手動無効", + "Manual group order": "手動グループ順序", + "Manual routing groups must not contain duplicates": "手動ルーティンググループを重複させることはできません", "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "ユーザー情報レスポンスのフィールドをローカルユーザー属性にマッピングします。ネストされたパスをサポートします (例: ocs.data.id)。", "Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.": "モデル識別子をGemini APIバージョンにマッピングします。特定の一致が見つからない場合は、`default`エントリが適用されます。", "Map request model names to actual provider model names (JSON format)": "リクエストのモデル名を実際のプロバイダーのモデル名にマッピング (JSON 形式)", @@ -2846,6 +2857,8 @@ "More...": "その他...", "Most-used models in the selected period and category": "選択した期間とカテゴリで最も使われているモデル", "Move": "移動", + "Move {{group}} down": "{{group}} を下へ移動", + "Move {{group}} up": "{{group}} を上へ移動", "Move a request header": "リクエストヘッダーを移動", "Move affiliate rewards to your main balance": "アフィリエイト報酬をメイン残高に移動する", "Move down": "下へ移動", @@ -2947,6 +2960,7 @@ "No app usage data available for this model.": "このモデルのアプリ利用データはまだありません。", "No apps match the selected filters": "条件に一致するアプリはありません", "No Auth": "認証なし", + "No automatic routing groups": "利用可能な自動ルーティンググループがありません", "No available models": "利用可能なモデルがありません", "No available Web chat links": "利用可能なWebチャットリンクがありません", "No backup": "バックアップなし", @@ -2988,6 +3002,7 @@ "No files match the accepted types.": "許可された種類に一致するファイルがありません。", "No group found.": "グループが見つかりません。", "No group-based rate limits configured. Click \"Add group\" to get started.": "グループベースのレート制限が設定されていません。\"グループを追加\" をクリックして開始してください。", + "No groups found": "グループが見つかりません", "No groups match your search": "検索に一致するグループがありません", "No groups yet. Add a group to get started.": "グループはまだありません。グループを追加して開始してください。", "No header overrides configured.": "ヘッダーのオーバーライドが設定されていません。", @@ -3066,6 +3081,8 @@ "No rules yet": "ルールがありません", "No rules yet. Add a group below to get started.": "まだルールがありません。下にグループを追加して開始してください。", "No separate media pricing configured.": "個別のメディア料金は設定されていません。", + "No automatic routing groups are currently available": "現在利用可能な自動ルーティンググループがありません", + "No automatic routing groups are currently available. Select manual routing or contact an administrator.": "現在利用可能な自動ルーティンググループがありません。手動ルーティングを選択するか、管理者に連絡してください。", "No status code mappings configured.": "ステータスコードのマッピングが設定されていません。", "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "このマーチャントにはまだストアがありません。戻り URL を設定し、作成をクリックして最初のペアを作成してください。", "No subscription plans yet": "サブスクリプションプランがありません", @@ -3810,6 +3827,7 @@ "Remaining:": "残り:", "Remark": "備考", "Remove": "削除", + "Remove {{group}}": "{{group}} を削除", "Remove {{value}}": "{{value}} を削除", "Remove ${{amount}}": "${{amount}}を削除", "Remove all log entries created before the selected timestamp.": "選択したタイムスタンプより前に作成されたすべてのログエントリを削除します。", @@ -3828,6 +3846,7 @@ "Remove tag": "タグを削除", "Remove the target field": "ターゲットフィールドを削除", "Remove tier": "ティアを削除", + "Remove unavailable manual routing groups before saving": "保存する前に利用できない手動ルーティンググループを削除してください", "Removed": "削除済み", "Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}": "{{removed}} 個の重複キーを削除しました。削除前:{{before}}、削除後:{{after}}", "Removed Models ({{count}})": "削除済みモデル ({{count}})", @@ -3881,6 +3900,7 @@ "Requests are mapped to model aliases, provider channels, retries, failover rules, task polling flows, response handling, and settlement events.": "リクエストはモデルエイリアス、プロバイダーチャネル、リトライ、フェイルオーバールール、タスクポーリング、レスポンス処理、精算イベントにマッピングされます。", "Requests per minute": "1分あたりのリクエスト数", "requests served": "処理されたリクエスト", + "Requests try groups in the order shown below.": "リクエストは以下の順序でグループを試します。", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "リクエストはこのワーカーに転送されます。末尾のスラッシュは自動的に削除されます。", "Requests:": "リクエスト:", "Require email verification for new accounts": "新しいアカウントにメール認証を要求する", @@ -4011,6 +4031,7 @@ "Routing & Overrides": "ルーティングと上書き", "Routing and reliability governance": "ルーティングと信頼性のガバナンス", "Routing fabric": "ルーティングファブリック", + "Routing group unavailable": "ルーティンググループは利用できません", "Routing matrix": "ルーティング行列", "Routing policy": "ルーティングポリシー", "Routing resilience": "ルーティングの回復力", @@ -4116,6 +4137,7 @@ "Search deployments...": "デプロイを検索...", "Search feature in development": "検索機能は開発中です", "Search group names...": "グループ名を検索...", + "Search groups": "グループを検索", "Search groups...": "グループを検索...", "Search missing models": "不足しているモデルを検索", "Search model name, provider, endpoint, or tag...": "モデル名、プロバイダー、エンドポイント、タグを検索...", @@ -4143,7 +4165,6 @@ "Security boundary": "セキュリティ境界", "Security Check": "セキュリティチェック", "Security verification": "セキュリティ確認", - "Verify your identity before permanently deleting your account.": "アカウントを完全に削除する前に本人確認を行ってください。", "Seed": "シード", "Seedance": "Seedance(動画生成)", "Select": "選択", @@ -4166,9 +4187,11 @@ "Select all (filtered)": "フィルタ結果をすべて選択(S)", "Select all models": "すべてのモデルを選択", "Select All Visible": "表示中のすべてを選択", + "Select an available automatic routing group": "利用可能な自動ルーティンググループを選択してください", "Select an operation mode and enter the amount": "操作モードを選択し、金額を入力してください", "Select announcement type": "アナウンスメントタイプを選択", "Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。", + "Select at least one manual routing group": "手動ルーティンググループを1つ以上選択してください", "Select at least one target model": "少なくとも1つの対象モデルを選択してください", "Select body font": "本文フォントを選択", "Select border radius": "角丸を選択", @@ -4184,6 +4207,7 @@ "Select end time": "終了時間を選択", "Select from presets or type custom identifier.": "プリセットから選択するか、カスタム識別子を入力してください。", "Select granularity": "粒度を選択", + "Select groups": "グループを選択", "Select groups (leave empty to keep current)": "グループを選択 (現在の設定を維持するには空のままにしてください)", "Select interface density": "インターフェイスの密度を選択", "Select items...": "項目を選択...", @@ -4341,6 +4365,8 @@ "Slug is required": "スラッグは必須です", "Slug must be less than 100 characters": "スラッグは100文字以内にしてください", "Smallest USD amount users can recharge (Epay)": "ユーザーがチャージできる最小USD金額 (Epay)", + "Automatic routing is disabled. The manual group order is used.": "自動ルーティングは無効です。手動のグループ順序を使用します。", + "Automatic routing is enabled. The system follows the selected automatic route group order.": "自動ルーティングは有効です。システムは選択した自動ルーティンググループの設定順序に従います。", "SMTP Email": "SMTPメール", "SMTP encryption": "SMTP 暗号化方式", "SMTP Host": "SMTPホスト", @@ -4519,6 +4545,7 @@ "System Prompt": "システムプロンプト", "System Prompt Concatenation": "システムプロンプトの連結", "System Prompt Override": "システムプロンプト上書き", + "System route: {{route}}": "システムルート:{{route}}", "System settings": "システム設定", "System Settings": "システム設定", "System setup wizard": "システムセットアップウィザード", @@ -4619,6 +4646,8 @@ "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "OAuthコールバック、Webhook、その他の外部統合に使用されるサーバーの公開URL", "The redesigned homepage follows the same lifecycle that MAX API manages in production: connect, govern, route, settle, and audit.": "再設計されたホームページは、MAX APIが本番で管理する接続、統制、ルーティング、精算、監査という同じライフサイクルに沿っています。", "The requested chat preset does not exist or has been removed.": "要求されたチャットプリセットは存在しないか、削除されました。", + "The saved routing group {{route}} is no longer available. Select another group before saving.": "保存済みのルーティンググループ {{route}} は利用できなくなりました。保存する前に別のグループを選択してください。", + "The saved routing group {{route}} is not available for new selections. It will be preserved unless routing is changed.": "保存済みのルーティンググループ {{route}} は新たな選択では利用できません。ルーティングを変更しない限り、そのまま保持されます。", "The setup wizard will use this database during initialization.": "セットアップウィザードは初期化時にこのデータベースを使用します。", "The site is not available at the moment.": "現在、このサイトは利用できません。", "The slug is appended to the URL:": "スラッグがURLに追加されます:", @@ -4887,6 +4916,7 @@ "Unable to read clipboard": "クリップボードを読み取れません", "Unauthorized": "未認証", "Unauthorized Access": "不正アクセス", + "Unavailable": "利用不可", "Unbind": "連携解除", "Unbind failed": "連携解除に失敗しました", "Unbound {{provider}}": "{{provider}}の連携を解除しました", @@ -5114,6 +5144,7 @@ "Verify routing with Playground or your client": "Playground またはクライアントでルーティングを確認", "Verify Setup": "設定を確認", "Verify your database connection": "データベース接続を確認", + "Verify your identity before permanently deleting your account.": "アカウントを完全に削除する前に本人確認を行ってください。", "Verifying credentials and pulling stores from your Pancake account...": "認証情報を検証し、Pancake アカウントからストアを取得しています...", "Version Overrides": "バージョンオーバーライド", "Vertex AI": "Vertex AI", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 42632cd3..560dc390 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -24,6 +24,7 @@ "{{category}} Models": "Модели {{category}}", "{{completed}}/{{total}} completed": "{{completed}}/{{total}} выполнено", "{{count}} {{entity}} selected. Bulk actions toolbar is available.": "Выбрано: {{count}} {{entity}}. Панель массовых действий доступна.", + "{{count}} / {{max}} groups selected": "Выбрано групп: {{count}} / {{max}}", "{{count}} blocking issue(s)": "Блокирующих проблем: {{count}}", "{{count}} channel(s) deleted": "Удалено {{count}} каналов", "{{count}} channel(s) disabled": "Отключено {{count}} каналов", @@ -587,6 +588,7 @@ "Auto-fill when one field exists and another is missing": "Автозаполнение, когда одно поле есть, а другое отсутствует", "Auto-retry status codes": "Коды авто-повтора", "Automatic mode upgrades with STARTTLS when available; STARTTLS mode requires server support": "Автоматический режим использует STARTTLS при доступности; режим STARTTLS требует поддержки сервера", + "Automatic routing group": "Группа автоматической маршрутизации", "Automatic STARTTLS": "Автоматический STARTTLS", "Automatically disable channel on repeated failures": "Автоматически отключать канал при повторных неудачах", "Automatically disable channels exceeding this response time": "Автоматически отключать каналы, превышающие это время ответа", @@ -870,6 +872,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "Китайский", + "Choose a system-defined group chain. Requests follow its configured order.": "Выберите системную цепочку групп. Запросы выполняются в настроенном порядке.", "Choose a username": "Выберите имя пользователя", "Choose an amount and payment method": "Выберите сумму и способ оплаты", "Choose between default expanded, compact icon-only, or full layout mode": "Выберите между развернутым по умолчанию, компактным (только иконки) или полным режимом макета", @@ -1445,6 +1448,7 @@ "Disable": "Отключить", "Disable 2FA": "Отключить 2FA", "Disable All": "Отключить все", + "Disable and select groups": "Отключить и выбрать группы", "Disable on failure": "Отключить при сбое", "Disable selected channels": "Отключить выбранные каналы", "Disable selected models": "Отключить выбранные модели", @@ -1513,6 +1517,7 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Дважды проверьте конфигурацию ниже. Ваша система будет заблокирована до завершения инициализации.", "Download": "Скачать", + "Drag {{group}} to reorder": "Перетащите {{group}}, чтобы изменить порядок", "Draw": "Рисование", "Drawing": "Рисование", "Drawing logs": "Журналы рисования", @@ -1656,6 +1661,7 @@ "Enable Request Passthrough": "Включить сквозную передачу запросов", "Enable selected channels": "Включить выбранные каналы", "Enable selected models": "Включить выбранные модели", + "Enable automatic routing": "Включить автоматическую маршрутизацию", "Enable SSL/TLS": "Включить SSL/TLS", "Enable SSRF Protection": "Включить защиту от SSRF", "Enable streaming mode for the test request.": "Включить потоковый режим для тестового запроса.", @@ -2330,6 +2336,8 @@ "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Если группа auto включена по умолчанию, новые токены создаются с auto вместо пустой группы.", "If default auto group is enabled, newly created tokens start with the configured default auto route.": "Если включена автоматическая группа по умолчанию, новые токены начинают с настроенного автомаршрута по умолчанию.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Если привязанный канал не работает и повторная попытка удалась через другой канал, привязка обновляется на успешный канал.", + "If the current group fails, try the next group in the configured automatic route order.": "При сбое текущей группы использовать следующую группу в настроенном порядке автоматической маршрутизации.", + "If the current group fails, try the next group in the manual order.": "При сбое текущей группы использовать следующую группу в заданном вручную порядке.", "If this keeps happening, please report it on GitHub Issues.": "Если проблема повторяется, сообщите о ней в GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Если вы предоставляете услуги генеративного ИИ населению материкового Китая, вы будете выполнять юридические обязанности, включая регистрацию, оценку безопасности, безопасность контента, обработку жалоб, маркировку сгенерированного контента, хранение журналов и защиту персональных данных.", "Ignore": "Игнорировать", @@ -2637,7 +2645,10 @@ "Manage Vendors": "Управление поставщиками", "Manage your LinuxDO OAuth app": "Управление приложением OAuth LinuxDO", "Manage your security settings and account access": "Управление настройками безопасности и доступом к аккаунту", + "Manual {{count}} groups: {{groups}}": "Ручные группы ({{count}}): {{groups}}", "Manual Disabled": "Ручное отключение", + "Manual group order": "Ручной порядок групп", + "Manual routing groups must not contain duplicates": "Группы ручной маршрутизации не должны повторяться", "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "Сопоставление полей из ответа информации о пользователе с локальными атрибутами пользователя. Поддерживает вложенные пути (например, ocs.data.id).", "Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.": "Сопоставьте идентификаторы моделей с версиями Gemini API. Запись `default` применяется, если не найдено конкретного совпадения.", "Map request model names to actual provider model names (JSON format)": "Сопоставление имён моделей запроса реальным именам моделей провайдера (формат JSON)", @@ -2846,6 +2857,8 @@ "More...": "Подробнее...", "Most-used models in the selected period and category": "Самые используемые модели в выбранном периоде и категории", "Move": "Переместить", + "Move {{group}} down": "Переместить {{group}} вниз", + "Move {{group}} up": "Переместить {{group}} вверх", "Move a request header": "Переместить заголовок запроса", "Move affiliate rewards to your main balance": "Перевести партнерские вознаграждения на основной баланс", "Move down": "Переместить вниз", @@ -2947,6 +2960,7 @@ "No app usage data available for this model.": "Данные об использовании приложений для этой модели пока недоступны.", "No apps match the selected filters": "Нет приложений, соответствующих фильтрам", "No Auth": "Без auth", + "No automatic routing groups": "Нет групп автоматической маршрутизации", "No available models": "Нет доступных моделей", "No available Web chat links": "Нет доступных веб-ссылок для чата", "No backup": "Нет резервной копии", @@ -2988,6 +3002,7 @@ "No files match the accepted types.": "Нет файлов, соответствующих допустимым типам.", "No group found.": "Группа не найдена.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Групповые лимиты скорости не настроены. Нажмите \"Добавить группу\", чтобы начать.", + "No groups found": "Группы не найдены", "No groups match your search": "Нет групп, соответствующих вашему поиску", "No groups yet. Add a group to get started.": "Групп пока нет. Добавьте группу, чтобы начать.", "No header overrides configured.": "Нет настроенных переопределений заголовков.", @@ -3066,6 +3081,8 @@ "No rules yet": "Правила отсутствуют", "No rules yet. Add a group below to get started.": "Правил пока нет. Добавьте группу ниже, чтобы начать.", "No separate media pricing configured.": "Отдельные цены для медиа не настроены.", + "No automatic routing groups are currently available": "Сейчас нет доступных групп автоматической маршрутизации", + "No automatic routing groups are currently available. Select manual routing or contact an administrator.": "Сейчас нет доступных групп автоматической маршрутизации. Выберите ручную маршрутизацию или обратитесь к администратору.", "No status code mappings configured.": "Сопоставления кодов состояния не настроены.", "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "У этого продавца пока нет магазинов. Укажите URL возврата и нажмите Создать, чтобы создать первую пару.", "No subscription plans yet": "Планов подписки пока нет", @@ -3810,6 +3827,7 @@ "Remaining:": "Осталось:", "Remark": "Примечания", "Remove": "Удалить", + "Remove {{group}}": "Удалить {{group}}", "Remove {{value}}": "Удалить {{value}}", "Remove ${{amount}}": "Удалить ${{amount}}", "Remove all log entries created before the selected timestamp.": "Удалить все записи журнала, созданные до выбранной отметки времени.", @@ -3828,6 +3846,7 @@ "Remove tag": "Удалить тег", "Remove the target field": "Удалить целевое поле", "Remove tier": "Удалить уровень", + "Remove unavailable manual routing groups before saving": "Перед сохранением удалите недоступные группы ручной маршрутизации", "Removed": "Удалено", "Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}": "Удалено {{removed}} дублирующихся ключей. До: {{before}}, После: {{after}}", "Removed Models ({{count}})": "Удалённые модели ({{count}})", @@ -3881,6 +3900,7 @@ "Requests are mapped to model aliases, provider channels, retries, failover rules, task polling flows, response handling, and settlement events.": "Запросы сопоставляются с алиасами моделей, каналами поставщиков, повторными попытками, правилами failover, потоками опроса задач, обработкой ответов и событиями расчета.", "Requests per minute": "Запросов в минуту", "requests served": "обслуженных запросов", + "Requests try groups in the order shown below.": "Запросы используют группы в указанном ниже порядке.", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "Запросы будут перенаправлены этому воркеру. Конечные слеши удаляются автоматически.", "Requests:": "Запросы:", "Require email verification for new accounts": "Требовать подтверждение электронной почты для новых учетных записей", @@ -4011,6 +4031,7 @@ "Routing & Overrides": "Маршрутизация и переопределения", "Routing and reliability governance": "Управление маршрутизацией и надежностью", "Routing fabric": "Маршрутизирующая сеть", + "Routing group unavailable": "Группа маршрутизации недоступна", "Routing matrix": "Матрица маршрутизации", "Routing policy": "Политика маршрутизации", "Routing resilience": "Устойчивость маршрутизации", @@ -4116,6 +4137,7 @@ "Search deployments...": "Поиск развертываний...", "Search feature in development": "Функция поиска в разработке", "Search group names...": "Поиск названий групп...", + "Search groups": "Поиск групп", "Search groups...": "Поиск групп...", "Search missing models": "Поиск отсутствующих моделей", "Search model name, provider, endpoint, or tag...": "Поиск по названию модели, поставщику, endpoint или тегу...", @@ -4143,7 +4165,6 @@ "Security boundary": "Граница безопасности", "Security Check": "Проверка безопасности", "Security verification": "Подтверждение безопасности", - "Verify your identity before permanently deleting your account.": "Подтвердите свою личность перед окончательным удалением аккаунта.", "Seed": "Сид", "Seedance": "Seedance (генерация видео)", "Select": "Выбрать", @@ -4166,9 +4187,11 @@ "Select all (filtered)": "& Выбрать все отфильтрованные", "Select all models": "Выбрать все модели", "Select All Visible": "Выбрать все видимые", + "Select an available automatic routing group": "Выберите доступную группу автоматической маршрутизации", "Select an operation mode and enter the amount": "Выберите режим операции и введите сумму", "Select announcement type": "Выбрать тип объявления", "Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.", + "Select at least one manual routing group": "Выберите хотя бы одну группу ручной маршрутизации", "Select at least one target model": "Выберите хотя бы одну целевую модель", "Select body font": "Выберите шрифт текста", "Select border radius": "Выберите радиус скругления", @@ -4184,6 +4207,7 @@ "Select end time": "Выбрать время окончания", "Select from presets or type custom identifier.": "Выберите из предустановок или введите пользовательский идентификатор.", "Select granularity": "Выбрать детализацию", + "Select groups": "Выбрать группы", "Select groups (leave empty to keep current)": "Выбрать группы (оставьте пустым, чтобы сохранить текущие)", "Select interface density": "Выберите плотность интерфейса", "Select items...": "Выберите элементы...", @@ -4341,6 +4365,8 @@ "Slug is required": "Slug обязателен", "Slug must be less than 100 characters": "Slug должен содержать менее 100 символов", "Smallest USD amount users can recharge (Epay)": "Минимальная сумма в USD, которую пользователи могут пополнить (Epay)", + "Automatic routing is disabled. The manual group order is used.": "Автоматическая маршрутизация отключена. Используется ручной порядок групп.", + "Automatic routing is enabled. The system follows the selected automatic route group order.": "Автоматическая маршрутизация включена. Система следует настроенному порядку выбранной группы автоматической маршрутизации.", "SMTP Email": "Электронная почта SMTP", "SMTP encryption": "Шифрование SMTP", "SMTP Host": "Хост SMTP", @@ -4519,6 +4545,7 @@ "System Prompt": "Системное приглашение", "System Prompt Concatenation": "Конкатенация системных промптов", "System Prompt Override": "Переопределение системного промпта", + "System route: {{route}}": "Системный маршрут: {{route}}", "System settings": "Системные настройки", "System Settings": "Настройки системы", "System setup wizard": "Мастер настройки системы", @@ -4619,6 +4646,8 @@ "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "Публичный URL вашего сервера, используемый для OAuth-перенаправлений, вебхуков и других внешних интеграций", "The redesigned homepage follows the same lifecycle that MAX API manages in production: connect, govern, route, settle, and audit.": "Обновленная главная страница следует тому же жизненному циклу, которым MAX API управляет в production: подключение, управление, маршрутизация, расчет и аудит.", "The requested chat preset does not exist or has been removed.": "Запрошенный предустановленный чат не существует или был удален.", + "The saved routing group {{route}} is no longer available. Select another group before saving.": "Сохраненная группа маршрутизации {{route}} больше недоступна. Перед сохранением выберите другую группу.", + "The saved routing group {{route}} is not available for new selections. It will be preserved unless routing is changed.": "Сохраненная группа маршрутизации {{route}} недоступна для нового выбора. Она будет сохранена, если маршрутизацию не изменять.", "The setup wizard will use this database during initialization.": "Мастер настройки будет использовать эту базу данных при инициализации.", "The site is not available at the moment.": "Сайт в данный момент недоступен.", "The slug is appended to the URL:": "Слаг добавляется к URL:", @@ -4887,6 +4916,7 @@ "Unable to read clipboard": "Не удалось прочитать буфер обмена", "Unauthorized": "Не авторизован", "Unauthorized Access": "Несанкционированный доступ", + "Unavailable": "Недоступно", "Unbind": "Отвязать", "Unbind failed": "Не удалось отвязать", "Unbound {{provider}}": "{{provider}} отвязан", @@ -5114,6 +5144,7 @@ "Verify routing with Playground or your client": "Проверьте маршрутизацию через Playground или ваш клиент", "Verify Setup": "Проверить настройку", "Verify your database connection": "Проверьте подключение к базе данных", + "Verify your identity before permanently deleting your account.": "Подтвердите свою личность перед окончательным удалением аккаунта.", "Verifying credentials and pulling stores from your Pancake account...": "Проверяем учетные данные и загружаем магазины из вашего аккаунта Pancake...", "Version Overrides": "Переопределения версий", "Vertex AI": "Vertex AI", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 1dfd1b80..638893d6 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -24,6 +24,7 @@ "{{category}} Models": "Mô hình {{category}}", "{{completed}}/{{total}} completed": "Đã hoàn tất {{completed}}/{{total}}", "{{count}} {{entity}} selected. Bulk actions toolbar is available.": "Đã chọn {{count}} {{entity}}. Thanh thao tác hàng loạt hiện khả dụng.", + "{{count}} / {{max}} groups selected": "Đã chọn {{count}} / {{max}} nhóm", "{{count}} blocking issue(s)": "{{count}} vấn đề chặn", "{{count}} channel(s) deleted": "Đã xóa {{count}} kênh", "{{count}} channel(s) disabled": "Đã tắt {{count}} kênh", @@ -587,6 +588,7 @@ "Auto-fill when one field exists and another is missing": "Tự động điền khi một trường có giá trị và trường khác thiếu", "Auto-retry status codes": "Mã trạng thái tự thử lại", "Automatic mode upgrades with STARTTLS when available; STARTTLS mode requires server support": "Chế độ tự động nâng cấp lên STARTTLS khi có sẵn; chế độ STARTTLS yêu cầu máy chủ hỗ trợ", + "Automatic routing group": "Nhóm định tuyến tự động", "Automatic STARTTLS": "STARTTLS tự động", "Automatically disable channel on repeated failures": "Tự động vô hiệu hóa kênh khi xảy ra lỗi lặp lại", "Automatically disable channels exceeding this response time": "Tự động vô hiệu hóa các kênh vượt quá thời gian phản hồi này", @@ -870,6 +872,7 @@ "checkout.session.completed": "thanh toán.phiên.hoàn thành", "checkout.session.expired": "Phiên thanh toán đã hết hạn.", "Chinese": "Tiếng Trung", + "Choose a system-defined group chain. Requests follow its configured order.": "Chọn chuỗi nhóm do hệ thống định nghĩa. Yêu cầu sẽ tuân theo thứ tự đã cấu hình.", "Choose a username": "Chọn tên người dùng", "Choose an amount and payment method": "Chọn số tiền và phương thức thanh toán", "Choose between default expanded, compact icon-only, or full layout mode": "Chọn giữa chế độ mở rộng mặc định, chế độ chỉ biểu tượng thu gọn, hoặc chế độ bố cục đầy đủ", @@ -1445,6 +1448,7 @@ "Disable": "Vô hiệu hóa", "Disable 2FA": "Tắt 2FA", "Disable All": "Vô hiệu hóa tất cả", + "Disable and select groups": "Tắt và chọn nhóm", "Disable on failure": "Vô hiệu hóa khi lỗi", "Disable selected channels": "Vô hiệu hóa các kênh đã chọn", "Disable selected models": "Vô hiệu hóa các mô hình đã chọn", @@ -1513,6 +1517,7 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Kiểm tra kỹ lại cấu hình bên dưới. Hệ thống của bạn sẽ bị khóa cho đến khi quá trình khởi tạo hoàn tất.", "Download": "Tải xuống", + "Drag {{group}} to reorder": "Kéo {{group}} để sắp xếp lại", "Draw": "Vẽ", "Drawing": "Vẽ", "Drawing logs": "Nhật ký vẽ", @@ -1656,6 +1661,7 @@ "Enable Request Passthrough": "Bật Truyền qua Yêu cầu", "Enable selected channels": "Kích hoạt các kênh đã chọn", "Enable selected models": "Kích hoạt các mô hình đã chọn", + "Enable automatic routing": "Bật định tuyến tự động", "Enable SSL/TLS": "Bật SSL/TLS", "Enable SSRF Protection": "Kích hoạt Bảo vệ SSRF", "Enable streaming mode for the test request.": "Bật chế độ streaming cho yêu cầu thử nghiệm.", @@ -2330,6 +2336,8 @@ "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Nếu bật nhóm auto mặc định, token mới sẽ bắt đầu với auto thay vì nhóm trống.", "If default auto group is enabled, newly created tokens start with the configured default auto route.": "Nếu bật nhóm tự động mặc định, token mới tạo sẽ bắt đầu bằng tuyến tự động mặc định đã cấu hình.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Nếu kênh ưu tiên thất bại và thử lại thành công trên kênh khác, cập nhật ưu tiên sang kênh thành công.", + "If the current group fails, try the next group in the configured automatic route order.": "Nếu nhóm hiện tại thất bại, thử nhóm tiếp theo theo thứ tự định tuyến tự động đã cấu hình.", + "If the current group fails, try the next group in the manual order.": "Nếu nhóm hiện tại thất bại, thử nhóm tiếp theo theo thứ tự thủ công.", "If this keeps happening, please report it on GitHub Issues.": "Nếu sự cố tiếp tục xảy ra, vui lòng báo cáo trên GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Nếu bạn cung cấp dịch vụ AI tạo sinh cho công chúng tại Trung Quốc đại lục, bạn sẽ thực hiện các nghĩa vụ pháp lý bao gồm đăng ký, đánh giá an toàn, an toàn nội dung, xử lý khiếu nại, gắn nhãn nội dung được tạo, lưu giữ nhật ký và bảo vệ thông tin cá nhân.", "Ignore": "Bỏ qua", @@ -2637,7 +2645,10 @@ "Manage Vendors": "Quản lý Nhà cung cấp", "Manage your LinuxDO OAuth app": "Quản lý ứng dụng OAuth LinuxDO", "Manage your security settings and account access": "Quản lý cài đặt bảo mật và quyền truy cập tài khoản của bạn", + "Manual {{count}} groups: {{groups}}": "{{count}} nhóm thủ công: {{groups}}", "Manual Disabled": "Vô hiệu hóa thủ công", + "Manual group order": "Thứ tự nhóm thủ công", + "Manual routing groups must not contain duplicates": "Các nhóm định tuyến thủ công không được trùng lặp", "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "Ánh xạ các trường từ phản hồi thông tin người dùng sang thuộc tính người dùng cục bộ. Hỗ trợ đường dẫn lồng nhau (ví dụ: ocs.data.id).", "Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.": "Ánh xạ các mã định danh mô hình với các phiên bản API Gemini. Một mục `default` áp dụng khi không tìm thấy kết quả khớp cụ thể nào.", "Map request model names to actual provider model names (JSON format)": "Ánh xạ tên mô hình yêu cầu đến tên mô hình thực tế của nhà cung cấp (định dạng JSON)", @@ -2846,6 +2857,8 @@ "More...": "Thêm...", "Most-used models in the selected period and category": "Mô hình được dùng nhiều nhất trong khoảng thời gian và danh mục đã chọn", "Move": "Di chuyển", + "Move {{group}} down": "Di chuyển {{group}} xuống", + "Move {{group}} up": "Di chuyển {{group}} lên", "Move a request header": "Di chuyển header yêu cầu", "Move affiliate rewards to your main balance": "Chuyển phần thưởng liên kết vào số dư chính của bạn", "Move down": "Di chuyển xuống", @@ -2947,6 +2960,7 @@ "No app usage data available for this model.": "Chưa có dữ liệu sử dụng ứng dụng cho mô hình này.", "No apps match the selected filters": "Không có ứng dụng phù hợp bộ lọc", "No Auth": "Không xác thực", + "No automatic routing groups": "Không có nhóm định tuyến tự động", "No available models": "Không có mô hình khả dụng", "No available Web chat links": "Không có liên kết Web chat khả dụng", "No backup": "Chưa sao lưu", @@ -2988,6 +3002,7 @@ "No files match the accepted types.": "Không có tệp nào khớp với các loại được chấp nhận.", "No group found.": "Không tìm thấy nhóm.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Chưa cấu hình giới hạn tốc độ dựa trên nhóm. Nhấp \"Add group\" để bắt đầu.", + "No groups found": "Không tìm thấy nhóm", "No groups match your search": "Không có nhóm nào khớp với tìm kiếm của bạn", "No groups yet. Add a group to get started.": "Chưa có nhóm nào. Thêm một nhóm để bắt đầu.", "No header overrides configured.": "Không có ghi đè tiêu đề nào được cấu hình.", @@ -3066,6 +3081,8 @@ "No rules yet": "Chưa có quy tắc", "No rules yet. Add a group below to get started.": "Chưa có quy tắc nào. Thêm một nhóm bên dưới để bắt đầu.", "No separate media pricing configured.": "Chưa cấu hình giá phương tiện riêng.", + "No automatic routing groups are currently available": "Hiện không có nhóm định tuyến tự động khả dụng", + "No automatic routing groups are currently available. Select manual routing or contact an administrator.": "Hiện không có nhóm định tuyến tự động khả dụng. Hãy chọn định tuyến thủ công hoặc liên hệ quản trị viên.", "No status code mappings configured.": "Chưa cấu hình ánh xạ mã trạng thái.", "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "Merchant này chưa có cửa hàng. Đặt URL trả về và nhấn Tạo để tạo cặp đầu tiên.", "No subscription plans yet": "Chưa có gói đăng ký nào", @@ -3810,6 +3827,7 @@ "Remaining:": "Còn lại:", "Remark": "Nhận xét", "Remove": "Xóa", + "Remove {{group}}": "Xóa {{group}}", "Remove {{value}}": "Xóa {{value}}", "Remove ${{amount}}": "Xóa ${{amount}}", "Remove all log entries created before the selected timestamp.": "Xóa tất cả các mục nhật ký được tạo trước mốc thời gian đã chọn.", @@ -3828,6 +3846,7 @@ "Remove tag": "Xóa thẻ", "Remove the target field": "Xóa trường đích", "Remove tier": "Gỡ bậc", + "Remove unavailable manual routing groups before saving": "Xóa các nhóm định tuyến thủ công không khả dụng trước khi lưu", "Removed": "Đã xóa", "Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}": "Đã xóa {{removed}} khóa trùng lặp. Trước: {{before}}, Sau: {{after}}", "Removed Models ({{count}})": "Các mô hình đã xóa ({{count}})", @@ -3881,6 +3900,7 @@ "Requests are mapped to model aliases, provider channels, retries, failover rules, task polling flows, response handling, and settlement events.": "Yêu cầu được ánh xạ tới alias mô hình, kênh nhà cung cấp, retry, quy tắc failover, luồng polling tác vụ, xử lý phản hồi và sự kiện quyết toán.", "Requests per minute": "Yêu cầu mỗi phút", "requests served": "yêu cầu đã phục vụ", + "Requests try groups in the order shown below.": "Yêu cầu sẽ thử các nhóm theo thứ tự bên dưới.", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "Các yêu cầu sẽ được chuyển tiếp đến worker này. Dấu gạch chéo ở cuối được tự động loại bỏ.", "Requests:": "Yêu cầu:", "Require email verification for new accounts": "Yêu cầu xác minh email cho tài khoản mới", @@ -4011,6 +4031,7 @@ "Routing & Overrides": "Định tuyến & ghi đè", "Routing and reliability governance": "Quản trị định tuyến và độ tin cậy", "Routing fabric": "Mạng định tuyến", + "Routing group unavailable": "Nhóm định tuyến không khả dụng", "Routing matrix": "Ma trận định tuyến", "Routing policy": "Routing policy", "Routing resilience": "Khả năng phục hồi định tuyến", @@ -4116,6 +4137,7 @@ "Search deployments...": "Tìm kiếm triển khai...", "Search feature in development": "Tính năng tìm kiếm đang được phát triển", "Search group names...": "Tìm kiếm tên nhóm...", + "Search groups": "Tìm kiếm nhóm", "Search groups...": "Searching for group...", "Search missing models": "Tìm kiếm mô hình bị thiếu", "Search model name, provider, endpoint, or tag...": "Tìm tên mô hình, nhà cung cấp, endpoint hoặc thẻ...", @@ -4143,7 +4165,6 @@ "Security boundary": "Ranh giới bảo mật", "Security Check": "Kiểm tra bảo mật", "Security verification": "Xác minh bảo mật", - "Verify your identity before permanently deleting your account.": "Xác minh danh tính trước khi xóa vĩnh viễn tài khoản của bạn.", "Seed": "Seed", "Seedance": "Seedance", "Select": "Chọn", @@ -4166,9 +4187,11 @@ "Select all (filtered)": "Chọn tất cả (đã lọc)", "Select all models": "Chọn tất cả mô hình", "Select All Visible": "Chọn tất cả hiển thị", + "Select an available automatic routing group": "Chọn một nhóm định tuyến tự động khả dụng", "Select an operation mode and enter the amount": "Chọn chế độ thao tác và nhập số tiền", "Select announcement type": "Select notification type", "Select at least one field to overwrite.": "Chọn ít nhất một trường để ghi đè.", + "Select at least one manual routing group": "Chọn ít nhất một nhóm định tuyến thủ công", "Select at least one target model": "Chọn ít nhất một mô hình đích", "Select body font": "Chọn phông chữ nội dung", "Select border radius": "Chọn độ bo góc", @@ -4184,6 +4207,7 @@ "Select end time": "Chọn thời gian kết thúc", "Select from presets or type custom identifier.": "Chọn từ các cài đặt sẵn hoặc nhập mã định danh tùy chỉnh.", "Select granularity": "Select detail level", + "Select groups": "Chọn nhóm", "Select groups (leave empty to keep current)": "Chọn nhóm (để trống để giữ nguyên hiện tại)", "Select interface density": "Chọn mật độ giao diện", "Select items...": "Chọn các mục...", @@ -4341,6 +4365,8 @@ "Slug is required": "Slug là bắt buộc", "Slug must be less than 100 characters": "Slug phải ít hơn 100 ký tự", "Smallest USD amount users can recharge (Epay)": "Số tiền USD tối thiểu người dùng có thể nạp (Epay)", + "Automatic routing is disabled. The manual group order is used.": "Định tuyến tự động đã tắt. Hệ thống dùng thứ tự nhóm thủ công.", + "Automatic routing is enabled. The system follows the selected automatic route group order.": "Định tuyến tự động đã bật. Hệ thống tuân theo thứ tự đã cấu hình của nhóm định tuyến tự động được chọn.", "SMTP Email": "Email SMTP", "SMTP encryption": "Mã hóa SMTP", "SMTP Host": "Máy chủ SMTP", @@ -4519,6 +4545,7 @@ "System Prompt": "Lời nhắc hệ thống", "System Prompt Concatenation": "Ghép nối Lời nhắc Hệ thống", "System Prompt Override": "Ghi đè prompt hệ thống", + "System route: {{route}}": "Tuyến hệ thống: {{route}}", "System settings": "Cài đặt hệ thống", "System Settings": "Cài đặt hệ thống", "System setup wizard": "Trình hướng dẫn thiết lập hệ thống", @@ -4619,6 +4646,8 @@ "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "URL công khai của máy chủ, dùng cho callback OAuth, webhook và các tích hợp bên ngoài khác", "The redesigned homepage follows the same lifecycle that MAX API manages in production: connect, govern, route, settle, and audit.": "Trang chủ được thiết kế lại đi theo cùng vòng đời mà MAX API quản lý trong production: kết nối, quản trị, định tuyến, quyết toán và kiểm toán.", "The requested chat preset does not exist or has been removed.": "Cài đặt sẵn cuộc trò chuyện được yêu cầu không tồn tại hoặc đã bị xóa.", + "The saved routing group {{route}} is no longer available. Select another group before saving.": "Nhóm định tuyến đã lưu {{route}} không còn khả dụng. Hãy chọn nhóm khác trước khi lưu.", + "The saved routing group {{route}} is not available for new selections. It will be preserved unless routing is changed.": "Nhóm định tuyến đã lưu {{route}} không khả dụng cho lựa chọn mới. Nhóm này sẽ được giữ nguyên nếu bạn không thay đổi định tuyến.", "The setup wizard will use this database during initialization.": "Trình hướng dẫn thiết lập sẽ sử dụng cơ sở dữ liệu này trong quá trình khởi tạo.", "The site is not available at the moment.": "Trang web hiện không khả dụng.", "The slug is appended to the URL:": "Slug được gắn vào URL:", @@ -4887,6 +4916,7 @@ "Unable to read clipboard": "Không thể đọc bảng nhớ tạm", "Unauthorized": "Chưa xác thực", "Unauthorized Access": "Truy cập trái phép", + "Unavailable": "Không khả dụng", "Unbind": "Hủy liên kết", "Unbind failed": "Hủy liên kết thất bại", "Unbound {{provider}}": "Đã hủy liên kết {{provider}}", @@ -5114,6 +5144,7 @@ "Verify routing with Playground or your client": "Xác minh định tuyến bằng Playground hoặc client của bạn", "Verify Setup": "Xác minh thiết lập", "Verify your database connection": "Xác minh kết nối cơ sở dữ liệu của bạn", + "Verify your identity before permanently deleting your account.": "Xác minh danh tính trước khi xóa vĩnh viễn tài khoản của bạn.", "Verifying credentials and pulling stores from your Pancake account...": "Đang xác minh thông tin xác thực và lấy cửa hàng từ tài khoản Pancake của bạn...", "Version Overrides": "Ghi đè phiên bản", "Vertex AI": "Vertex AI", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 2888408b..b427c484 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -24,6 +24,7 @@ "{{category}} Models": "{{category}} 模型", "{{completed}}/{{total}} completed": "已完成 {{completed}}/{{total}}", "{{count}} {{entity}} selected. Bulk actions toolbar is available.": "已选择 {{count}} 个{{entity}}。批量操作工具栏现已可用。", + "{{count}} / {{max}} groups selected": "已选择 {{count}} / {{max}} 个分组", "{{count}} blocking issue(s)": "{{count}} 个阻断问题", "{{count}} channel(s) deleted": "已删除 {{count}} 个渠道", "{{count}} channel(s) disabled": "已禁用 {{count}} 个渠道", @@ -587,6 +588,7 @@ "Auto-fill when one field exists and another is missing": "在一个字段有值、另一个缺失时自动补齐", "Auto-retry status codes": "自动重试状态码", "Automatic mode upgrades with STARTTLS when available; STARTTLS mode requires server support": "自动模式会在服务端支持时升级到 STARTTLS;STARTTLS 模式要求服务端支持该能力", + "Automatic routing group": "自动路由分组", "Automatic STARTTLS": "自动 STARTTLS", "Automatically disable channel on repeated failures": "重复失败时自动禁用通道", "Automatically disable channels exceeding this response time": "自动禁用超出此响应时间的渠道", @@ -870,6 +872,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "中文", + "Choose a system-defined group chain. Requests follow its configured order.": "选择系统预设的分组链,请求将按其配置顺序执行。", "Choose a username": "选择一个用户名", "Choose an amount and payment method": "选择金额和支付方式", "Choose between default expanded, compact icon-only, or full layout mode": "选择默认展开、紧凑图标模式或完整布局模式", @@ -1445,6 +1448,7 @@ "Disable": "禁用", "Disable 2FA": "禁用 2FA", "Disable All": "禁用全部", + "Disable and select groups": "关闭并选择分组", "Disable on failure": "失败时禁用", "Disable selected channels": "禁用选定的渠道", "Disable selected models": "禁用选定的模型", @@ -1513,6 +1517,7 @@ "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。", "Download": "下载", + "Drag {{group}} to reorder": "拖拽 {{group}} 调整顺序", "Draw": "绘图", "Drawing": "绘图", "Drawing logs": "绘制日志", @@ -1656,6 +1661,7 @@ "Enable Request Passthrough": "启用请求透传", "Enable selected channels": "启用选定的渠道", "Enable selected models": "启用选定的模型", + "Enable automatic routing": "开启自动路由", "Enable SSL/TLS": "启用 SSL/TLS", "Enable SSRF Protection": "启用 SSRF 保护", "Enable streaming mode for the test request.": "为测试请求启用流式模式。", @@ -2330,6 +2336,8 @@ "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "如果启用默认 auto 分组,新建令牌会默认使用 auto,而不是空分组。", "If default auto group is enabled, newly created tokens start with the configured default auto route.": "如果启用默认自动分组,新建令牌会使用配置的默认自动链路。", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。", + "If the current group fails, try the next group in the configured automatic route order.": "当前分组失败时,按已配置的自动路由顺序尝试下一个分组。", + "If the current group fails, try the next group in the manual order.": "当前分组失败时,按手动顺序尝试下一个分组。", "If this keeps happening, please report it on GitHub Issues.": "如果问题持续出现,请到 GitHub Issues 反馈。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如果你在中国大陆向公众提供生成式人工智能服务,你将履行备案、安全评估、内容安全、投诉处理、生成内容标识、日志留存和个人信息保护等法律义务。", "Ignore": "忽略", @@ -2637,7 +2645,10 @@ "Manage Vendors": "管理供应商", "Manage your LinuxDO OAuth app": "管理你的 LinuxDO OAuth 应用", "Manage your security settings and account access": "管理您的安全设置和账户访问", + "Manual {{count}} groups: {{groups}}": "手动 {{count}} 个分组:{{groups}}", "Manual Disabled": "手动禁用", + "Manual group order": "手动分组顺序", + "Manual routing groups must not contain duplicates": "手动路由分组不能重复", "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "将用户信息响应中的字段映射到本地用户属性。支持嵌套路径(例如 ocs.data.id)。", "Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.": "将模型标识符映射到 Gemini API 版本。当未找到特定匹配项时,将应用 `default` 条目。", "Map request model names to actual provider model names (JSON format)": "将请求模型名称映射到实际提供商模型名称 (JSON 格式)", @@ -2846,6 +2857,8 @@ "More...": "更多...", "Most-used models in the selected period and category": "所选时间范围与分类下使用率最高的模型", "Move": "移动", + "Move {{group}} down": "下移 {{group}}", + "Move {{group}} up": "上移 {{group}}", "Move a request header": "移动请求头", "Move affiliate rewards to your main balance": "将推广奖励转移到您的主余额", "Move down": "下移", @@ -2947,6 +2960,7 @@ "No app usage data available for this model.": "该模型暂无应用使用数据。", "No apps match the selected filters": "没有匹配筛选条件的应用", "No Auth": "无认证", + "No automatic routing groups": "暂无可用自动路由分组", "No available models": "没有可用模型", "No available Web chat links": "没有可用的 Web 聊天链接", "No backup": "无备份", @@ -2988,6 +3002,7 @@ "No files match the accepted types.": "没有文件匹配接受的类型。", "No group found.": "未找到分组。", "No group-based rate limits configured. Click \"Add group\" to get started.": "未配置基于组的速率限制。点击“添加组”开始使用。", + "No groups found": "未找到分组", "No groups match your search": "没有组匹配您的搜索", "No groups yet. Add a group to get started.": "暂无分组,添加一个分组开始配置。", "No header overrides configured.": "未配置标头覆盖。", @@ -3066,6 +3081,8 @@ "No rules yet": "暂无规则", "No rules yet. Add a group below to get started.": "暂无规则。请在下方添加分组以开始。", "No separate media pricing configured.": "未配置单独的媒体定价。", + "No automatic routing groups are currently available": "当前没有可用的自动路由分组", + "No automatic routing groups are currently available. Select manual routing or contact an administrator.": "当前没有可用的自动路由分组。请选择手动路由或联系管理员。", "No status code mappings configured.": "未配置状态码映射。", "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "此商户暂无店铺。设置返回 URL 后点击创建来生成第一组配对。", "No subscription plans yet": "暂无订阅套餐", @@ -3810,6 +3827,7 @@ "Remaining:": "剩余:", "Remark": "备注", "Remove": "移除", + "Remove {{group}}": "移除 {{group}}", "Remove {{value}}": "移除 {{value}}", "Remove ${{amount}}": "移除 ${{amount}}", "Remove all log entries created before the selected timestamp.": "移除所选时间戳之前创建的所有日志条目。", @@ -3828,6 +3846,7 @@ "Remove tag": "移除标签", "Remove the target field": "删除目标字段", "Remove tier": "移除档位", + "Remove unavailable manual routing groups before saving": "保存前请移除不可用的手动路由分组", "Removed": "已移除", "Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}": "已移除 {{removed}} 个重复密钥。移除前:{{before}},移除后:{{after}}", "Removed Models ({{count}})": "已移除模型 ({{count}})", @@ -3881,6 +3900,7 @@ "Requests are mapped to model aliases, provider channels, retries, failover rules, task polling flows, response handling, and settlement events.": "请求会映射到模型别名、供应商渠道、重试、故障转移规则、任务轮询流程、响应处理和结算事件。", "Requests per minute": "每分钟请求数", "requests served": "服务请求数", + "Requests try groups in the order shown below.": "请求将按下方顺序尝试各分组。", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "请求将被转发到此 Worker。末尾的斜杠会自动移除。", "Requests:": "请求:", "Require email verification for new accounts": "要求新账户验证邮箱", @@ -4011,6 +4031,7 @@ "Routing & Overrides": "路由与覆盖", "Routing and reliability governance": "路由与可靠性治理", "Routing fabric": "路由网络", + "Routing group unavailable": "路由分组不可用", "Routing matrix": "路由矩阵", "Routing policy": "路由策略", "Routing resilience": "路由韧性", @@ -4116,6 +4137,7 @@ "Search deployments...": "搜索部署...", "Search feature in development": "搜索功能开发中", "Search group names...": "搜索分组名称...", + "Search groups": "搜索分组", "Search groups...": "搜索分组...", "Search missing models": "搜索缺失的模型", "Search model name, provider, endpoint, or tag...": "搜索模型名称、供应商、端点或标签...", @@ -4143,7 +4165,6 @@ "Security boundary": "安全边界", "Security Check": "安全验证", "Security verification": "安全验证", - "Verify your identity before permanently deleting your account.": "永久删除账号前,请先验证您的身份。", "Seed": "随机种子", "Seedance": "Seedance / 豆包视频", "Select": "选择", @@ -4166,9 +4187,11 @@ "Select all (filtered)": "全选(筛选结果)", "Select all models": "选择所有模型", "Select All Visible": "全选当前", + "Select an available automatic routing group": "请选择可用的自动路由分组", "Select an operation mode and enter the amount": "选择操作模式并输入金额", "Select announcement type": "选择公告类型", "Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。", + "Select at least one manual routing group": "请至少选择一个手动路由分组", "Select at least one target model": "请至少选择一个目标模型", "Select body font": "选择正文字体", "Select border radius": "选择圆角大小", @@ -4184,6 +4207,7 @@ "Select end time": "选择结束时间", "Select from presets or type custom identifier.": "从预设中选择或输入自定义标识符。", "Select granularity": "选择粒度", + "Select groups": "选择分组", "Select groups (leave empty to keep current)": "选择分组(留空以保持当前设置)", "Select interface density": "选择界面密度", "Select items...": "选择项目...", @@ -4341,6 +4365,8 @@ "Slug is required": "Slug 不能为空", "Slug must be less than 100 characters": "Slug 不能超过 100 个字符", "Smallest USD amount users can recharge (Epay)": "用户可以充值的最小美元金额 (Epay)", + "Automatic routing is disabled. The manual group order is used.": "自动路由已关闭,将使用手动分组顺序。", + "Automatic routing is enabled. The system follows the selected automatic route group order.": "自动路由已开启,系统将按照所选自动路由分组的配置顺序执行。", "SMTP Email": "SMTP 邮箱", "SMTP encryption": "SMTP 加密方式", "SMTP Host": "SMTP 主机", @@ -4519,6 +4545,7 @@ "System Prompt": "系统提示词", "System Prompt Concatenation": "系统提示词连接", "System Prompt Override": "系统提示覆盖", + "System route: {{route}}": "系统路由:{{route}}", "System settings": "系统设置", "System Settings": "系统设置", "System setup wizard": "系统设置向导", @@ -4619,6 +4646,8 @@ "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "服务器的公开URL,用于OAuth回调、Webhook和其他外部集成", "The redesigned homepage follows the same lifecycle that MAX API manages in production: connect, govern, route, settle, and audit.": "重新设计的主页遵循 MAX API 在生产中管理的同一生命周期:接入、治理、路由、结算和审计。", "The requested chat preset does not exist or has been removed.": "请求的聊天预设不存在或已被删除。", + "The saved routing group {{route}} is no longer available. Select another group before saving.": "已保存的路由分组 {{route}} 不再可用,请在保存前选择其他分组。", + "The saved routing group {{route}} is not available for new selections. It will be preserved unless routing is changed.": "已保存的路由分组 {{route}} 不能用于新的选择。只要不修改路由,就会保留该分组。", "The setup wizard will use this database during initialization.": "设置向导将在初始化过程中使用此数据库。", "The site is not available at the moment.": "该站点目前不可用。", "The slug is appended to the URL:": "别名将附加到 URL:", @@ -4887,6 +4916,7 @@ "Unable to read clipboard": "无法读取剪贴板", "Unauthorized": "未授权", "Unauthorized Access": "未经授权的访问", + "Unavailable": "不可用", "Unbind": "解绑", "Unbind failed": "解绑失败", "Unbound {{provider}}": "已解绑 {{provider}}", @@ -5114,6 +5144,7 @@ "Verify routing with Playground or your client": "使用 Playground 或你的客户端验证路由", "Verify Setup": "验证设置", "Verify your database connection": "验证数据库连接", + "Verify your identity before permanently deleting your account.": "永久删除账号前,请先验证您的身份。", "Verifying credentials and pulling stores from your Pancake account...": "正在验证凭证并从你的 Pancake 账户拉取店铺...", "Version Overrides": "版本覆盖", "Vertex AI": "Vertex AI", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 3ee1e998..5c4f3989 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -103,7 +103,6 @@ export const STATIC_I18N_KEYS = [ 'Successfully created {{count}} API Key(s)', 'Successfully deleted {{count}} API key(s)', 'Enter API key for this channel', - // Users 'Root', 'Deleted', diff --git a/web/default/src/lib/api.ts b/web/default/src/lib/api.ts index b3c711eb..e8e70764 100644 --- a/web/default/src/lib/api.ts +++ b/web/default/src/lib/api.ts @@ -20,6 +20,7 @@ import axios, { type AxiosRequestConfig } from 'axios' import { t } from 'i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' +import type { AutoGroupRoute } from '@/lib/auto-routes' declare module 'axios' { export interface AxiosRequestConfig { @@ -199,6 +200,7 @@ export async function getUserModels(): Promise<{ export async function getUserGroups(): Promise<{ success: boolean message?: string + auto_routes?: AutoGroupRoute[] data?: Record< string, { desc: string; ratio: number | string; auto?: boolean; groups?: string[] } From d5718250ee369937e5590f7ea32b93b33b443958 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 5 Aug 2026 21:54:41 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/model.go | 7 +- controller/token.go | 31 +- controller/token_test.go | 37 +++ middleware/auth.go | 2 +- middleware/distributor.go | 44 ++- middleware/distributor_routing_test.go | 12 + service/token_routing.go | 11 +- service/token_routing_test.go | 28 ++ .../components/api-key-routing-editor.tsx | 305 ++++++++++-------- .../keys/components/api-keys-columns.tsx | 4 +- .../components/api-keys-mutate-drawer.tsx | 83 +++-- .../features/keys/lib/api-key-form.test.ts | 56 ++++ .../src/features/keys/lib/api-key-form.ts | 33 +- web/default/src/i18n/locales/ru.json | 2 +- 14 files changed, 450 insertions(+), 205 deletions(-) diff --git a/controller/model.go b/controller/model.go index 62d6bb80..92e203b2 100644 --- a/controller/model.go +++ b/controller/model.go @@ -242,11 +242,14 @@ func buildModelListGroups(userGroup string, tokenGroup string) modelListGroups { func getEnabledModelsForGroups(ownerGroups []string) []string { models := make([]string, 0) + seen := make(map[string]struct{}) for _, ownerGroup := range ownerGroups { for _, groupModel := range model.GetGroupEnabledModels(ownerGroup) { - if !common.StringsContains(models, groupModel) { - models = append(models, groupModel) + if _, exists := seen[groupModel]; exists { + continue } + seen[groupModel] = struct{}{} + models = append(models, groupModel) } } return models diff --git a/controller/token.go b/controller/token.go index 45791a9e..27602234 100644 --- a/controller/token.go +++ b/controller/token.go @@ -60,7 +60,7 @@ type tokenMutationRequest struct { ModelLimits string `json:"model_limits"` AllowIps *string `json:"allow_ips"` Group *string `json:"group"` - CrossGroupRetry bool `json:"cross_group_retry"` + CrossGroupRetry *bool `json:"cross_group_retry"` Routing json.RawMessage `json:"routing"` } @@ -97,13 +97,21 @@ func resolveTokenMutationRouting(request tokenMutationRequest, current *model.To } if request.Group != nil { + crossGroupRetry := false + if request.CrossGroupRetry != nil { + crossGroupRetry = *request.CrossGroupRetry + } return service.NormalizeLegacyTokenRoutingPolicy( - service.LegacyTokenRoutingPolicy(*request.Group, request.CrossGroupRetry, userGroup), + service.LegacyTokenRoutingPolicy(*request.Group, crossGroupRetry, userGroup), userGroup, ) } if creating { - return service.NormalizeLegacyTokenRoutingPolicy(service.DefaultTokenRoutingPolicy(), userGroup) + policy := service.DefaultTokenRoutingPolicy() + if request.CrossGroupRetry != nil { + policy.RetryOnFailure = *request.CrossGroupRetry + } + return service.NormalizeLegacyTokenRoutingPolicy(policy, userGroup) } if current == nil { return model.TokenRoutingPolicy{}, errors.New("current token is required") @@ -113,16 +121,21 @@ func resolveTokenMutationRouting(request tokenMutationRequest, current *model.To return model.TokenRoutingPolicy{}, fmt.Errorf("invalid token routing policy: %w", err) } if stored != nil { - return stored.Clone(), nil + policy := stored.Clone() + if request.CrossGroupRetry != nil { + policy.RetryOnFailure = *request.CrossGroupRetry + } + return policy, nil + } + policy := service.LegacyTokenRoutingPolicy(current.Group, current.CrossGroupRetry, userGroup) + if request.CrossGroupRetry != nil { + policy.RetryOnFailure = *request.CrossGroupRetry } - return service.NormalizeLegacyTokenRoutingPolicy( - service.LegacyTokenRoutingPolicy(current.Group, current.CrossGroupRetry, userGroup), - userGroup, - ) + return service.NormalizeLegacyTokenRoutingPolicy(policy, userGroup) } func tokenMutationUpdatesRouting(request tokenMutationRequest) bool { - return len(request.Routing) > 0 || request.Group != nil + return len(request.Routing) > 0 || request.Group != nil || request.CrossGroupRetry != nil } func GetAllTokens(c *gin.Context) { diff --git a/controller/token_test.go b/controller/token_test.go index 4cb8caac..795dba1e 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -761,6 +761,43 @@ func TestResolveTokenMutationRoutingPreservesStoredPolicyWhenRoutingIsOmitted(t } } +func TestUpdateTokenChangesCrossGroupRetryWhenSubmittedWithoutGroup(t *testing.T) { + db := setupTokenControllerTestDB(t) + token := seedToken(t, db, 1, "retry-only-token", "retryonly1234token5678") + if err := db.Model(token).Updates(map[string]any{ + "group": "default", + "cross_group_retry": true, + }).Error; err != nil { + t.Fatalf("failed to prepare token: %v", err) + } + + body := map[string]any{ + "id": token.Id, + "name": "retry-only-token-updated", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "cross_group_retry": false, + } + ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + UpdateToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected retry-only update to succeed, 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.CrossGroupRetry { + t.Fatal("expected explicit false cross-group retry to be persisted") + } +} + func TestUpdateTokenPreservesLegacyRoutingWhenRoutingFieldsAreOmitted(t *testing.T) { db := setupTokenControllerTestDB(t) token := seedToken(t, db, 1, "legacy-empty", "legacy1234empty5678") diff --git a/middleware/auth.go b/middleware/auth.go index f7a4e6d0..1d7ca1a6 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -467,7 +467,7 @@ func TokenAuth() func(c *gin.Context) { userGroup := userCache.Group routingPolicy, legacyRouting, routingErr := service.ResolveTokenRoutingPolicy(token, userGroup) if routingErr != nil { - abortWithOpenAiMessage(c, http.StatusForbidden, routingErr.Error()) + abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, routingErr.Error())) return } tokenGroup, crossGroupRetry := service.ProjectTokenRoutingPolicy(routingPolicy) diff --git a/middleware/distributor.go b/middleware/distributor.go index 4cdb89e3..0b756482 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -121,21 +121,18 @@ 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 routePlan != nil && !routePlan.Legacy && len(routePlan.OrderedGroups) > 0 { - firstGroup := routePlan.OrderedGroups[0] - if model.IsChannelEnabledForGroupModel(firstGroup, modelRequest.Model, preferred.Id) { - selectGroup = firstGroup - common.SetContextKey(c, constant.ContextKeyAutoGroup, firstGroup) - channel = preferred - affinityUsable = true - service.MarkChannelAffinityUsed(c, firstGroup, preferred.Id) - if routePlan.RetryOnFailure && len(routePlan.OrderedGroups) > 1 { - common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, 1) - common.SetContextKey(c, constant.ContextKeyAutoGroupRetryIndex, 1) - } else { - common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, 0) - common.SetContextKey(c, constant.ContextKeyAutoGroupRetryIndex, 0) - } + if group, groupIndex, matched := findAffinityRouteGroup(routePlan, modelRequest.Model, preferred.Id, model.IsChannelEnabledForGroupModel); matched { + selectGroup = group + common.SetContextKey(c, constant.ContextKeyAutoGroup, group) + channel = preferred + affinityUsable = true + service.MarkChannelAffinityUsed(c, group, preferred.Id) + if routePlan.RetryOnFailure && groupIndex < len(routePlan.OrderedGroups)-1 { + common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, groupIndex+1) + common.SetContextKey(c, constant.ContextKeyAutoGroupRetryIndex, 1) + } else { + common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, groupIndex) + common.SetContextKey(c, constant.ContextKeyAutoGroupRetryIndex, 0) } } else if service.IsAutoRouteKey(usingGroup) { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) @@ -204,6 +201,23 @@ func channelSupportsRequestPath(channel *model.Channel, requestPath string) bool return model.ChannelSupportsRequestPath(channel, requestPath) } +func findAffinityRouteGroup( + plan *service.TokenRoutePlan, + modelName string, + channelID int, + isUsable func(group string, modelName string, channelID int) bool, +) (string, int, bool) { + if plan == nil || plan.Legacy || isUsable == nil { + return "", 0, false + } + for index, group := range plan.OrderedGroups { + if isUsable(group, modelName, channelID) { + return group, index, true + } + } + return "", 0, false +} + // getModelFromRequest 从请求中读取模型信息 // 根据 Content-Type 自动处理: // - application/json diff --git a/middleware/distributor_routing_test.go b/middleware/distributor_routing_test.go index 70cde522..18c73687 100644 --- a/middleware/distributor_routing_test.go +++ b/middleware/distributor_routing_test.go @@ -10,6 +10,7 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/setting" "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" "github.com/gin-gonic/gin" @@ -112,3 +113,14 @@ func TestPlaygroundExplicitGroupBypassesStoredTokenRoutePlan(t *testing.T) { _, hasPlan := common.GetContextKey(ctx, constant.ContextKeyTokenRoutePlan) require.False(t, hasPlan) } + +func TestFindAffinityRouteGroupUsesFirstMatchingRouteGroup(t *testing.T) { + group, index, ok := findAffinityRouteGroup(&service.TokenRoutePlan{ + OrderedGroups: []string{"base", "deluxe"}, + }, "gpt-affinity-route", 7, func(group string, modelName string, channelID int) bool { + return group == "deluxe" && modelName == "gpt-affinity-route" && channelID == 7 + }) + require.True(t, ok) + require.Equal(t, "deluxe", group) + require.Equal(t, 1, index) +} diff --git a/service/token_routing.go b/service/token_routing.go index 72ca1da0..0034fc98 100644 --- a/service/token_routing.go +++ b/service/token_routing.go @@ -32,6 +32,9 @@ type tokenRoutingValidationOptions struct { func LegacyTokenRoutingPolicy(group string, crossGroupRetry bool, userGroup string) model.TokenRoutingPolicy { group = strings.TrimSpace(group) + if group == "" { + group = strings.TrimSpace(userGroup) + } if setting.IsAutoRouteKey(group) { return model.TokenRoutingPolicy{ Version: model.TokenRoutingPolicyVersion, @@ -41,7 +44,9 @@ func LegacyTokenRoutingPolicy(group string, crossGroupRetry bool, userGroup stri } } if group == "" { - group = strings.TrimSpace(userGroup) + policy := DefaultTokenRoutingPolicy() + policy.RetryOnFailure = crossGroupRetry + return policy } return model.TokenRoutingPolicy{ Version: model.TokenRoutingPolicyVersion, @@ -207,14 +212,16 @@ func BuildContextTokenRoutePlan(c *gin.Context) (*TokenRoutePlan, error) { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) var policy model.TokenRoutingPolicy - _, hasStoredPolicy := common.GetContextKey(c, constant.ContextKeyTokenRoutingPolicy) + hasStoredPolicy := false if raw, ok := common.GetContextKey(c, constant.ContextKeyTokenRoutingPolicy); ok { switch value := raw.(type) { case model.TokenRoutingPolicy: policy = value.Clone() + hasStoredPolicy = true case *model.TokenRoutingPolicy: if value != nil { policy = value.Clone() + hasStoredPolicy = true } } } diff --git a/service/token_routing_test.go b/service/token_routing_test.go index 877709c0..de436167 100644 --- a/service/token_routing_test.go +++ b/service/token_routing_test.go @@ -1,12 +1,15 @@ package service import ( + "net/http/httptest" "testing" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/setting" "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -62,6 +65,31 @@ func TestLegacyEmptyGroupUsesAuthenticatedUserGroup(t *testing.T) { require.Equal(t, []string{"vip"}, plan.OrderedGroups) } +func TestLegacyEmptyGroupWithoutAuthenticatedGroupUsesDefaultAutomaticRoute(t *testing.T) { + setupTokenRoutingTestGroups(t) + + policy := LegacyTokenRoutingPolicy("", false, "") + require.Equal(t, model.TokenRoutingModeSmart, policy.Mode) + require.Equal(t, setting.GetDefaultAutoRouteKey(), policy.Route) + + plan, err := BuildTokenRoutePlan(policy, "") + require.NoError(t, err) + require.Equal(t, []string{"default", "vip"}, plan.OrderedGroups) +} + +func TestBuildContextTokenRoutePlanTreatsInvalidStoredPolicyAsLegacy(t *testing.T) { + setupTokenRoutingTestGroups(t) + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyTokenRoutingPolicy, "not-a-policy") + + plan, err := BuildContextTokenRoutePlan(ctx) + require.NoError(t, err) + require.True(t, plan.Legacy) + require.Equal(t, []string{"default"}, plan.OrderedGroups) +} + func TestResolveTokenRoutingPolicySkipsUnavailableStoredManualGroups(t *testing.T) { setupTokenRoutingTestGroups(t) token := &model.Token{} diff --git a/web/default/src/features/keys/components/api-key-routing-editor.tsx b/web/default/src/features/keys/components/api-key-routing-editor.tsx index 810d4fbf..9a01b0c0 100644 --- a/web/default/src/features/keys/components/api-key-routing-editor.tsx +++ b/web/default/src/features/keys/components/api-key-routing-editor.tsx @@ -176,47 +176,44 @@ function ManualGroupRow(props: ManualGroupRowProps) { ) } -function ManualGroupEditor({ - value, - options, - disabled, - onChange, -}: { +type ManualGroupEditorProps = { value: string[] options: ApiKeyGroupOption[] disabled?: boolean onChange: (groups: string[]) => void -}) { +} + +function ManualGroupEditor(props: ManualGroupEditorProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const optionMap = useMemo( - () => new Map(options.map((option) => [option.value, option])), - [options] + () => new Map(props.options.map((option) => [option.value, option])), + [props.options] ) - const atLimit = value.length >= MAX_MANUAL_ROUTING_GROUPS + const atLimit = props.value.length >= MAX_MANUAL_ROUTING_GROUPS useEffect(() => { - if (!disabled) return + if (!props.disabled) return const timeoutId = window.setTimeout(() => setOpen(false), 0) return () => window.clearTimeout(timeoutId) - }, [disabled]) + }, [props.disabled]) const toggleGroup = (group: string) => { - if (disabled) return - if (value.includes(group)) { - onChange(value.filter((item) => item !== group)) + if (props.disabled) return + if (props.value.includes(group)) { + props.onChange(props.value.filter((item) => item !== group)) return } - if (!atLimit) onChange([...value, group]) + if (!atLimit) props.onChange([...props.value, group]) } const moveGroup = (index: number, direction: 'up' | 'down') => { - if (disabled) return + if (props.disabled) return const target = direction === 'up' ? index - 1 : index + 1 - if (target < 0 || target >= value.length) return - const next = [...value] + if (target < 0 || target >= props.value.length) return + const next = [...props.value] ;[next[index], next[target]] = [next[target], next[index]] - onChange(next) + props.onChange(next) } return ( @@ -225,55 +222,64 @@ function ManualGroupEditor({ {t('Manual group order')} {t('{{count}} / {{max}} groups selected', { - count: value.length, + count: props.value.length, max: MAX_MANUAL_ROUTING_GROUPS, })} { - if (!disabled) setOpen(nextOpen) + if (!props.disabled) setOpen(nextOpen) }} > - - } - > - {value.length === 0 && ( - - {t('Select groups')} - - )} - {value.map((group) => ( - - {group} +
+ + } + > + {props.value.length === 0 && ( + + {t('Select groups')} + + )} + {props.value.map((group) => ( + + {group} + + ))} + +
+ {props.value.map((group) => ( + - - ))} - - +
+
{t('No groups found')} - {options.map((option) => { - const selected = value.includes(option.value) + {props.options.map((option) => { + const selected = props.value.includes(option.value) return ( toggleGroup(option.value)} > @@ -316,30 +322,30 @@ function ManualGroupEditor({

{t('Requests try groups in the order shown below.')}

- {value.length === 0 ? ( + {props.value.length === 0 ? (
{t('Select at least one manual routing group')}
) : ( { - if (!disabled) onChange(groups) + if (!props.disabled) props.onChange(groups) }} className='overflow-hidden rounded-md border' > - {value.map((group, index) => ( + {props.value.map((group, index) => ( - onChange(value.filter((item) => item !== removed)) + props.onChange(props.value.filter((item) => item !== removed)) } /> ))} @@ -349,6 +355,94 @@ function ManualGroupEditor({ ) } +type AutomaticRouteSelectionProps = Pick< + ApiKeyRoutingEditorProps, + 'autoRouteOptions' | 'disabled' | 'onRouteChange' | 'route' | 'routesLoading' +> + +function AutomaticRouteSelection(props: AutomaticRouteSelectionProps) { + const { t } = useTranslation() + + if (props.routesLoading) { + return ( +
+ + +
+ ) + } + + if (props.autoRouteOptions.length === 0) { + return ( + + + ) + } + + return ( + + {props.autoRouteOptions.map((option) => { + const selected = props.route === option.value + return ( + + ) + })} + + ) +} + export function ApiKeyRoutingEditor(props: ApiKeyRoutingEditorProps) { const { t } = useTranslation() const smart = props.mode === 'smart' @@ -408,76 +502,13 @@ export function ApiKeyRoutingEditor(props: ApiKeyRoutingEditorProps) { )}

- {props.routesLoading ? ( -
- - -
- ) : props.autoRouteOptions.length === 0 ? ( - - - ) : ( - - {props.autoRouteOptions.map((option) => { - const selected = props.route === option.value - return ( - - ) - })} - - )} + {props.route && !props.routesLoading && !activeAutoRoute && (
Date: Thu, 6 Aug 2026 00:12:00 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/bun.lock | 33 ++++--- web/default/package.json | 1 + .../api-key-routing-editor.test.tsx | 88 +++++++++++-------- 3 files changed, 73 insertions(+), 49 deletions(-) diff --git a/web/bun.lock b/web/bun.lock index 57ec35c8..67908e88 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -73,6 +73,7 @@ "@tanstack/react-query-devtools": "^5.100.14", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.18", + "@testing-library/react": "^16.3.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", @@ -769,6 +770,10 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="], "@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="], @@ -797,6 +802,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -985,9 +992,9 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], @@ -999,6 +1006,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "array-source": ["array-source@0.0.4", "", {}, "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw=="], "assign-symbols": ["assign-symbols@1.0.0", "", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="], @@ -1255,6 +1264,8 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -1737,6 +1748,8 @@ "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], @@ -2025,6 +2038,8 @@ "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.8.0", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -2725,6 +2740,8 @@ "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -2757,6 +2774,8 @@ "streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -2765,6 +2784,8 @@ "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2821,8 +2842,6 @@ "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-fetch/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], @@ -2837,8 +2856,6 @@ "d3/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "eslint/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -2863,8 +2880,6 @@ "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2878,7 +2893,5 @@ "eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "simplify-geojson/concat-stream/readable-stream/string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], - - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/web/default/package.json b/web/default/package.json index 60b7488d..dd867ab4 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -82,6 +82,7 @@ "@tanstack/react-query-devtools": "^5.100.14", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.18", + "@testing-library/react": "^16.3.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", diff --git a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx index 9f549098..7fca37f5 100644 --- a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx +++ b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx @@ -17,8 +17,10 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { createReactTestEnvironment } from '@/test/react' +import { cleanup, fireEvent, render, within } from '@testing-library/react' import assert from 'node:assert/strict' -import { after, before, describe, test } from 'node:test' +import { after, afterEach, before, describe, test } from 'node:test' +import { I18nextProvider } from 'react-i18next' import { MAX_MANUAL_ROUTING_GROUPS } from '../lib/api-key-form' import { ApiKeyRoutingEditor } from './api-key-routing-editor' @@ -26,6 +28,8 @@ const testEnv = createReactTestEnvironment() before(() => testEnv.setup()) +afterEach(() => cleanup()) + after(() => testEnv.teardown()) const groups = Array.from( @@ -34,46 +38,52 @@ const groups = Array.from( ) describe('ApiKeyRoutingEditor', () => { - test('keeps the maximum manual selection inside one full-width trigger', async () => { - const view = await testEnv.render( - ({ - value: group, - label: group, - desc: group, - ratio: 1, - }))} - defaultManualGroups={groups} - onModeChange={() => undefined} - onRouteChange={() => undefined} - onManualGroupsChange={() => undefined} - onRetryOnFailureChange={() => undefined} - /> + test('keeps the maximum manual selection in one accessible selector', () => { + let changedGroups: string[] | undefined + const view = render( + + ({ + value: group, + label: group, + desc: group, + ratio: 1, + }))} + defaultManualGroups={groups} + onModeChange={() => undefined} + onRouteChange={() => undefined} + onManualGroupsChange={(nextGroups) => { + changedGroups = nextGroups + }} + onRetryOnFailureChange={() => undefined} + /> + ) - try { - const trigger = - view.container.querySelector('[role="combobox"]') - assert.ok(trigger) - assert.match(trigger.className, /\bw-full\b/) - assert.ok(trigger.querySelector('.lucide-chevrons-up-down')) - assert.equal(view.container.querySelector('.lucide-x'), null) - for (const group of groups) { - assert.match(trigger.textContent || '', new RegExp(group)) - const removeButton = view.container.querySelector( - `button[aria-label="Remove ${group}"]` - ) - assert.ok(removeButton) - assert.ok(removeButton.querySelector('.lucide-trash-2')) - assert.equal(trigger.contains(removeButton), false) - } - } finally { - await view.unmount() + const trigger = view.getByRole('combobox', { name: 'Select groups' }) + assert.equal(trigger.getAttribute('aria-expanded'), 'false') + for (const group of groups) { + within(trigger).getByText(group) + } + + fireEvent.click(trigger) + + assert.equal(trigger.getAttribute('aria-expanded'), 'true') + + const removeButtons = view.getAllByRole('button', { + name: /^Remove group-/, + }) + assert.equal(removeButtons.length, MAX_MANUAL_ROUTING_GROUPS) + for (const removeButton of removeButtons) { + assert.equal(trigger.contains(removeButton), false) } + + fireEvent.click(removeButtons[0]) + assert.deepEqual(changedGroups, groups.slice(1)) }) }) From d3abca49f9b317a29dd18ff74ea683b8548ba478 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 6 Aug 2026 00:23:26 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/keys/components/api-key-routing-editor.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx index 7fca37f5..57ee6a43 100644 --- a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx +++ b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx @@ -38,7 +38,7 @@ const groups = Array.from( ) describe('ApiKeyRoutingEditor', () => { - test('keeps the maximum manual selection in one accessible selector', () => { + test('keeps the maximum manual selection in one accessible selector', (): void => { let changedGroups: string[] | undefined const view = render( @@ -57,7 +57,7 @@ describe('ApiKeyRoutingEditor', () => { defaultManualGroups={groups} onModeChange={() => undefined} onRouteChange={() => undefined} - onManualGroupsChange={(nextGroups) => { + onManualGroupsChange={(nextGroups: string[]): void => { changedGroups = nextGroups }} onRetryOnFailureChange={() => undefined}