Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions controller/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,24 @@ func GetUserGroups(c *gin.Context) {
}
}
}
if _, ok := userUsableGroups["auto"]; ok {
usableGroups["auto"] = map[string]interface{}{
"ratio": "自动",
"desc": setting.GetUsableGroupDescription("auto"),
}
for _, route := range service.GetUserAutoRoutes(userGroup, true) {
addAutoRouteUsableGroup(usableGroups, route)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": usableGroups,
})
}

func addAutoRouteUsableGroup(usableGroups map[string]map[string]interface{}, route setting.AutoGroupRoute) {
if _, exists := usableGroups[route.Key]; exists {
return
}
usableGroups[route.Key] = map[string]interface{}{
"ratio": "自动",
"desc": route.Name,
"auto": true,
"groups": route.Groups,
}
}
28 changes: 28 additions & 0 deletions controller/group_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package controller

import (
"testing"

"github.com/MAX-API-Next/MAX-API/setting"
"github.com/stretchr/testify/require"
)

func TestAddAutoRouteUsableGroupDoesNotOverwriteRatioGroup(t *testing.T) {
usableGroups := map[string]map[string]interface{}{
"auto:fast": {
"ratio": 0.5,
"desc": "real ratio group",
},
}

addAutoRouteUsableGroup(usableGroups, setting.AutoGroupRoute{
Key: "auto:fast",
Name: "Fast route",
Groups: []string{"vip"},
})

require.Equal(t, map[string]interface{}{
"ratio": 0.5,
"desc": "real ratio group",
}, usableGroups["auto:fast"])
}
1 change: 1 addition & 0 deletions controller/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func GetStatus(c *gin.Context) {
"password_login_enabled": common.PasswordLoginEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"default_auto_route": setting.GetDefaultAutoRouteKey(),
"log_audit_enabled": common.LogRequestContentEnabled || common.LogResponseContentEnabled,
"log_request_content_enabled": common.LogRequestContentEnabled,
"log_response_content_enabled": common.LogResponseContentEnabled,
Expand Down
9 changes: 5 additions & 4 deletions controller/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
"github.com/MAX-API-Next/MAX-API/relay/helper"
"github.com/MAX-API-Next/MAX-API/service"
"github.com/MAX-API-Next/MAX-API/setting"
"github.com/MAX-API-Next/MAX-API/setting/operation_setting"
"github.com/MAX-API-Next/MAX-API/types"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -178,7 +179,7 @@ type modelListGroups struct {
func getModelListGroups(c *gin.Context) (modelListGroups, error) {
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
if userGroup == "" && (tokenGroup == "" || tokenGroup == "auto") {
if userGroup == "" && (tokenGroup == "" || setting.IsAutoRouteKey(tokenGroup)) {
var err error
userGroup, err = model.GetUserGroup(c.GetInt("id"), false)
if err != nil {
Expand All @@ -196,11 +197,11 @@ func getContextModelListGroups(c *gin.Context) modelListGroups {
}

func buildModelListGroups(userGroup string, tokenGroup string) modelListGroups {
if tokenGroup == "auto" {
if setting.IsAutoRouteKey(tokenGroup) {
return modelListGroups{
userGroup: userGroup,
tokenGroup: tokenGroup,
ownerGroups: service.GetUserAutoGroup(userGroup),
ownerGroups: service.GetUserAutoGroupByRoute(userGroup, tokenGroup),
}
}

Expand Down Expand Up @@ -269,7 +270,7 @@ func ListModels(c *gin.Context, modelType int) {
}
} else {
var models []string
if groups.tokenGroup == "auto" {
if setting.IsAutoRouteKey(groups.tokenGroup) {
for _, autoGroup := range ownerGroups {
groupModels := model.GetGroupEnabledModels(autoGroup)
for _, g := range groupModels {
Expand Down
9 changes: 9 additions & 0 deletions controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,15 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "AutoGroupRoutes":
_, err = setting.ParseAutoGroupRoutesConfig(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "自动链路配置失败: " + err.Error(),
})
return
}
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
Expand Down
8 changes: 6 additions & 2 deletions controller/perf_metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strconv"

perfmetrics "github.com/MAX-API-Next/MAX-API/pkg/perf_metrics"
"github.com/MAX-API-Next/MAX-API/setting"
"github.com/MAX-API-Next/MAX-API/setting/ratio_setting"

"github.com/gin-gonic/gin"
Expand All @@ -19,7 +20,10 @@ func GetPerfMetricsSummary(c *gin.Context) {
}
}

activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
activeGroups := lo.Keys(ratio_setting.GetGroupRatioCopy())
for _, route := range setting.GetAutoRoutes() {
activeGroups = append(activeGroups, route.Key)
}
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
Expand Down Expand Up @@ -77,6 +81,6 @@ func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupRes
activeRatios := ratio_setting.GetGroupRatioCopy()
return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool {
_, ok := activeRatios[g.Group]
return ok || g.Group == "auto"
return ok || setting.IsAutoRouteKey(g.Group)
})
}
1 change: 1 addition & 0 deletions controller/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func GetPricing(c *gin.Context) {
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": service.GetUserAutoGroup(group),
"auto_routes": service.GetUserAutoRoutes(group, true),
"pricing_version": "a42d372ccf0b5dd13ecf71203521f9d2",
})
}
Expand Down
26 changes: 26 additions & 0 deletions controller/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"fmt"
"net/http"
"strconv"
"strings"

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/i18n"
"github.com/MAX-API-Next/MAX-API/model"
"github.com/MAX-API-Next/MAX-API/service"
"github.com/MAX-API-Next/MAX-API/setting/operation_setting"

"github.com/gin-gonic/gin"
Expand All @@ -30,6 +32,22 @@ func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
return maskedTokens
}

func validateAssignableTokenGroup(c *gin.Context, group string) bool {
group = strings.TrimSpace(group)
if group == "" {
return true
}
userGroup := c.GetString("group")
if userGroup == "" {
userGroup = c.GetString("user_group")
}
if service.CanUseTokenGroup(userGroup, group) {
return true
}
common.ApiErrorI18n(c, i18n.MsgTokenGroupNotAssignable, map[string]any{"Group": group})
return false
}

func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
Expand Down Expand Up @@ -157,6 +175,10 @@ func AddToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
}
token.Group = strings.TrimSpace(token.Group)
if !validateAssignableTokenGroup(c, token.Group) {
return
}
// 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
Expand Down Expand Up @@ -271,6 +293,10 @@ func UpdateToken(c *gin.Context) {
if statusOnly != "" {
cleanToken.Status = token.Status
} else {
token.Group = strings.TrimSpace(token.Group)
if !validateAssignableTokenGroup(c, token.Group) {
return
}
// If you add more fields, please also update token.Update()
cleanToken.Name = token.Name
cleanToken.ExpiredTime = token.ExpiredTime
Expand Down
140 changes: 140 additions & 0 deletions controller/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import (
"testing"

"github.com/MAX-API-Next/MAX-API/common"
appi18n "github.com/MAX-API-Next/MAX-API/i18n"
"github.com/MAX-API-Next/MAX-API/model"
"github.com/MAX-API-Next/MAX-API/setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
Expand Down Expand Up @@ -114,6 +116,51 @@ func setupTokenControllerTestDB(t *testing.T) *gorm.DB {
return db
}

func setupHiddenAutoRouteForTokenTest(t *testing.T) {
t.Helper()

userGroupsSnapshot := setting.GetUserUsableGroupsCopy()
userGroupsBytes, err := common.Marshal(userGroupsSnapshot)
if err != nil {
t.Fatalf("failed to marshal user group snapshot: %v", err)
}
autoRoutesSnapshot := setting.AutoGroupRoutes2JsonString()
t.Cleanup(func() {
if err := setting.UpdateUserUsableGroupsByJSONString(string(userGroupsBytes)); err != nil {
t.Fatalf("failed to restore user groups: %v", err)
}
if err := setting.UpdateAutoGroupRoutesByJsonString(autoRoutesSnapshot); err != nil {
t.Fatalf("failed to restore auto routes: %v", err)
}
})

if err := setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`); err != nil {
t.Fatalf("failed to set user usable groups: %v", err)
}
if err := setting.UpdateAutoGroupRoutesByJsonString(`{
"version": 1,
"default_route": "auto",
"routes": [
{
"key": "auto",
"name": "Auto",
"enabled": true,
"user_selectable": true,
"groups": ["default"]
},
{
"key": "auto:internal",
"name": "Internal",
"enabled": true,
"user_selectable": false,
"groups": ["vip"]
}
]
}`); err != nil {
t.Fatalf("failed to set auto routes: %v", err)
}
}

func openTokenControllerExternalDB(t *testing.T, dialect string, dsn string) (*gorm.DB, *bool) {
t.Helper()

Expand Down Expand Up @@ -206,6 +253,11 @@ func newAuthenticatedContext(t *testing.T, method string, target string, body an
return ctx, recorder
}

func setAuthenticatedUserGroup(ctx *gin.Context, group string) {
ctx.Set("group", group)
ctx.Set("user_group", group)
}

func decodeAPIResponse(t *testing.T, recorder *httptest.ResponseRecorder) tokenAPIResponse {
t.Helper()

Expand Down Expand Up @@ -470,6 +522,49 @@ func TestGetTokenMasksKeyInResponse(t *testing.T) {
}
}

func TestAddTokenRejectsNonSelectableAutoRoute(t *testing.T) {
db := setupTokenControllerTestDB(t)
setupHiddenAutoRouteForTokenTest(t)
if err := appi18n.Init(); err != nil {
t.Fatalf("failed to initialize i18n: %v", err)
}

body := map[string]any{
"name": "hidden-route-token",
"expired_time": -1,
"remain_quota": 100,
"unlimited_quota": true,
"model_limits_enabled": false,
"model_limits": "",
"group": "auto:internal",
"cross_group_retry": true,
}

ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1)
setAuthenticatedUserGroup(ctx, "default")
ctx.Request.Header.Set("Accept-Language", "zh-CN")
AddToken(ctx)

response := decodeAPIResponse(t, recorder)
if response.Success {
t.Fatalf("expected hidden auto route token creation to fail")
}
if !strings.Contains(response.Message, "auto:internal") {
t.Fatalf("expected error message to mention hidden route, got %q", response.Message)
}
if !strings.Contains(response.Message, "无权访问") {
t.Fatalf("expected localized denial message, got %q", response.Message)
}

var count int64
if err := db.Model(&model.Token{}).Count(&count).Error; err != nil {
t.Fatalf("failed to count tokens: %v", err)
}
if count != 0 {
t.Fatalf("expected no token to be created, got %d", count)
}
}

func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678")
Expand Down Expand Up @@ -506,6 +601,51 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
}
}

func TestUpdateTokenRejectsNonSelectableAutoRoute(t *testing.T) {
db := setupTokenControllerTestDB(t)
setupHiddenAutoRouteForTokenTest(t)
if err := appi18n.Init(); err != nil {
t.Fatalf("failed to initialize i18n: %v", err)
}
token := seedToken(t, db, 1, "editable-token", "reject1234route5678")

body := map[string]any{
"id": token.Id,
"name": "updated-token",
"expired_time": -1,
"remain_quota": 100,
"unlimited_quota": true,
"model_limits_enabled": false,
"model_limits": "",
"group": "auto:internal",
"cross_group_retry": true,
}

ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1)
setAuthenticatedUserGroup(ctx, "default")
ctx.Request.Header.Set("Accept-Language", "zh-CN")
UpdateToken(ctx)

response := decodeAPIResponse(t, recorder)
if response.Success {
t.Fatalf("expected hidden auto route token update to fail")
}
if !strings.Contains(response.Message, "auto:internal") {
t.Fatalf("expected error message to mention hidden route, got %q", response.Message)
}
if !strings.Contains(response.Message, "无权访问") {
t.Fatalf("expected localized denial message, got %q", response.Message)
}

var stored model.Token
if err := db.First(&stored, token.Id).Error; err != nil {
t.Fatalf("failed to reload token: %v", err)
}
if stored.Group != "default" {
t.Fatalf("expected token group to remain default, got %q", stored.Group)
}
}

func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "owned-token", "owner1234token5678")
Expand Down
Loading
Loading