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
9 changes: 8 additions & 1 deletion common/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
Expand All @@ -14,6 +15,11 @@ import (
"github.com/MAX-API-Next/MAX-API/constant"
)

func SessionCookieSecureForServerAddress(serverAddress string) bool {
parsed, err := url.Parse(strings.TrimSpace(serverAddress))
return err == nil && strings.EqualFold(parsed.Scheme, "https") && parsed.Host != ""
}

var (
Port = flag.Int("port", 3000, "the listening port")
PrintVersion = flag.Bool("version", false, "print version and exit")
Expand Down Expand Up @@ -174,7 +180,8 @@ func initConstantEnv() {
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", constant.DefaultErrorLogEnabled)
// 任务轮询时查询的最大数量
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", constant.DefaultTaskQueryLimit)
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
// 异步任务超时时间(分钟)。已确认上游 ID 的任务失败后退款;无法确认
// 上游是否接受的提交占位只标记人工核对,避免错误退款。0 表示禁用。
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", constant.DefaultTaskTimeoutMinutes)

soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
Expand Down
15 changes: 15 additions & 0 deletions common/session_cookie_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package common

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestSessionCookieSecureForServerAddress(t *testing.T) {
require.True(t, SessionCookieSecureForServerAddress("https://max-api.example"))
require.True(t, SessionCookieSecureForServerAddress(" HTTPS://max-api.example/path "))
require.False(t, SessionCookieSecureForServerAddress("http://max-api.example"))
require.False(t, SessionCookieSecureForServerAddress("localhost:3000"))
require.False(t, SessionCookieSecureForServerAddress(""))
}
22 changes: 18 additions & 4 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,9 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
if channel == nil {
return fmt.Errorf("channel cannot be empty")
}
if err := service.ValidateStatusCodeMapping(channel.GetStatusCodeMapping()); err != nil {
return fmt.Errorf("渠道状态码映射[status code mapping]格式错误:%s", err.Error())
}
// 校验 channel settings
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
Expand Down Expand Up @@ -1381,6 +1384,21 @@ func ManageMultiKeys(c *gin.Context) {
return
}

// Avoid allocating a permanent per-channel lock for arbitrary nonexistent
// channel IDs. The mutation still reloads after taking the lock because
// multi-key operations rewrite the complete ChannelInfo value.
if _, err := model.GetChannelById(request.ChannelId, false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "渠道不存在",
})
return
}

lock := model.GetChannelPollingLock(request.ChannelId)
lock.Lock()
defer lock.Unlock()

channel, err := model.GetChannelById(request.ChannelId, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
Expand Down Expand Up @@ -1408,10 +1426,6 @@ func ManageMultiKeys(c *gin.Context) {
})
}

lock := model.GetChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()

switch request.Action {
case "get_key_status":
keys := channel.GetKeys()
Expand Down
13 changes: 13 additions & 0 deletions controller/channel_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,16 @@ func TestBuildChannelsForInsertUsesIndependentNamesWithKeyPrefixes(t *testing.T)
assert.Equal(t, "bravo-key-2", channels[1].Key)
assert.Equal(t, []string{"OpenAI Prod alpha-ke", "OpenAI Prod bravo-ke"}, names)
}

func TestValidateChannelRejectsInvalidStatusCodeMapping(t *testing.T) {
invalidMapping := `{"429":999}`
channel := &model.Channel{
Name: "OpenAI Prod",
Type: 1,
Key: "key",
Models: "gpt-test",
StatusCodeMapping: &invalidMapping,
}

require.ErrorContains(t, validateChannel(channel, true), "status code mapping")
}
84 changes: 84 additions & 0 deletions controller/channel_multi_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package controller

import (
"net/http"
"sync/atomic"
"testing"
"time"

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/model"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func TestManageMultiKeysValidatesThenReloadsChannelUnderPollingLock(t *testing.T) {
db := openTokenControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.Log{}, &model.User{}))

previousMemoryCacheEnabled := common.MemoryCacheEnabled
common.MemoryCacheEnabled = false
t.Cleanup(func() {
common.MemoryCacheEnabled = previousMemoryCacheEnabled
})

channel := model.Channel{
Name: "multi-key-lock-test",
Key: "first-key\nsecond-key",
Status: common.ChannelStatusEnabled,
Models: "gpt-4o",
Group: "default",
ChannelInfo: model.ChannelInfo{
IsMultiKey: true,
MultiKeySize: 2,
},
}
require.NoError(t, db.Create(&channel).Error)

var channelReads atomic.Int32
bothReads := make(chan struct{})
require.NoError(t, db.Callback().Query().Before("gorm:query").Register("test:observe-multi-key-channel-load", func(tx *gorm.DB) {
if tx.Statement.Table == "channels" && channelReads.Add(1) == 2 {
close(bothReads)
}
}))

lock := model.GetChannelPollingLock(channel.Id)
lock.Lock()
results := make(chan tokenAPIResponse, 2)
for _, keyIndex := range []int{0, 1} {
keyIndex := keyIndex
go func() {
ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/channel/multi-key", MultiKeyManageRequest{
ChannelId: channel.Id,
Action: "disable_key",
KeyIndex: &keyIndex,
}, 1)
ManageMultiKeys(ctx)
results <- decodeAPIResponse(t, recorder)
}()
}

validatedBeforeLock := false
select {
case <-bothReads:
validatedBeforeLock = true
case <-time.After(250 * time.Millisecond):
}
lock.Unlock()
require.True(t, validatedBeforeLock, "multi-key handlers must validate channel existence before allocating a polling lock")

for range 2 {
select {
case result := <-results:
require.True(t, result.Success, "multi-key mutation failed: %s", result.Message)
case <-time.After(2 * time.Second):
t.Fatal("multi-key mutation did not finish")
}
}

var stored model.Channel
require.NoError(t, db.First(&stored, channel.Id).Error)
require.Equal(t, 2, stored.ChannelInfo.MultiKeyStatusList[0])
require.Equal(t, 2, stored.ChannelInfo.MultiKeyStatusList[1])
}
4 changes: 2 additions & 2 deletions controller/midjourney.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ func GetAllMidjourney(c *gin.Context) {

if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
midjourney.ImageUrl = service.BuildMidjourneyImageURL(system_setting.ServerAddress, midjourney.MjId, midjourney.UserId, time.Now())
items[i] = midjourney
}
}
Expand All @@ -297,7 +297,7 @@ func GetUserMidjourney(c *gin.Context) {

if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
midjourney.ImageUrl = service.BuildMidjourneyImageURL(system_setting.ServerAddress, midjourney.MjId, midjourney.UserId, time.Now())
items[i] = midjourney
}
}
Expand Down
11 changes: 7 additions & 4 deletions controller/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,15 @@ func ListModels(c *gin.Context, modelType int) {
Type: "model",
}
}
c.JSON(200, gin.H{
response := gin.H{
"data": useranthropicModels,
"first_id": useranthropicModels[0].ID,
"has_more": false,
"last_id": useranthropicModels[len(useranthropicModels)-1].ID,
})
}
if len(useranthropicModels) > 0 {
response["first_id"] = useranthropicModels[0].ID
response["last_id"] = useranthropicModels[len(useranthropicModels)-1].ID
}
c.JSON(200, response)
case constant.ChannelTypeGemini:
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
for i, model := range userOpenAiModels {
Expand Down
21 changes: 21 additions & 0 deletions controller/model_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,27 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
require.NotContains(t, ids, "zz-token-unpriced-model")
}

func TestListModelsAnthropicAllowsEmptyModelList(t *testing.T) {
withSelfUseModeDisabled(t)

recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true)
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{})

ListModels(ctx, constant.ChannelTypeAnthropic)

require.Equal(t, http.StatusOK, recorder.Code)
var payload struct {
Data []dto.AnthropicModel `json:"data"`
HasMore bool `json:"has_more"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
require.Empty(t, payload.Data)
require.False(t, payload.HasMore)
}

func TestCheckUpdatePasswordRequiresCurrentPassword(t *testing.T) {
db := setupModelListControllerTestDB(t)
hashedPassword, err := common.Password2Hash("CurrentPassword123")
Expand Down
7 changes: 6 additions & 1 deletion controller/model_meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,12 @@ func DeleteModelMeta(c *gin.Context) {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
var m model.Model
if err := model.DB.First(&m, id).Error; err != nil {
common.ApiError(c, err)
return
}
if err := m.Delete(); err != nil {
common.ApiError(c, err)
return
}
Expand Down
Loading
Loading