diff --git a/common/constants.go b/common/constants.go index e33a64b221fc..c3c5af7762e4 100644 --- a/common/constants.go +++ b/common/constants.go @@ -104,6 +104,8 @@ var AutomaticDisableChannelEnabled = false var AutomaticEnableChannelEnabled = false var QuotaRemindThreshold = 1000 var PreConsumedQuota = 500 +var RetryAvoidUsedChannelEnabled = false +var RetryPriorityMode = "sequential" // "sequential" 或 "round-robin" var RetryTimes = 0 diff --git a/controller/relay.go b/controller/relay.go index 72ea3e24c7cb..27abb41a7a24 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -7,6 +7,7 @@ import ( "io" "log" "net/http" + "strconv" "strings" "github.com/QuantumNous/new-api/common" @@ -186,6 +187,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { break } + // 如果 channel 为 nil(该优先级的所有渠道都被排除),切换到下一个优先级 + // If channel is nil (all channels at this priority have been excluded), switch to next priority + if channel == nil { + retryParam.IncreasePriorityIndex() + logger.LogInfo(c, fmt.Sprintf("优先级用尽,切换到下一个优先级。当前 priorityIndex: %d", retryParam.GetPriorityIndex())) + continue + } + + logger.LogInfo(c, fmt.Sprintf("选择渠道 #%d,retry=%d, priorityIndex=%d, mode=%s", channel.Id, retryParam.GetRetry(), retryParam.GetPriorityIndex(), common.RetryPriorityMode)) addUsedChannel(c, channel.Id) requestBody, bodyErr := common.GetRequestBody(c) if bodyErr != nil { @@ -219,6 +229,20 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) { break } + + // 注意:在 round-robin 模式下,不需要在这里增加 priorityIndex + // GetRandomSatisfiedChannel 中的模运算会自动处理优先级循环 + // Note: In round-robin mode, no need to increase priorityIndex here + // The modulo operation in GetRandomSatisfiedChannel handles priority cycling automatically + } + + // 如果所有重试都完成了,但没有找到可用渠道,返回错误 + // If all retries are exhausted but no available channel found, return error + if newAPIError == nil && len(c.GetStringSlice("use_channel")) == 0 { + newAPIError = types.NewError( + fmt.Errorf("分组 %s 下模型 %s 的所有可用渠道都已被排除或不存在", relayInfo.TokenGroup, relayInfo.OriginModelName), + types.ErrorCodeGetChannelFailed, + ) } useChannel := c.GetStringSlice("use_channel") @@ -282,6 +306,21 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service AutoBan: &autoBanInt, }, nil } + + // 如果开关开启且处于重试阶段,从 use_channel 构建排除集合 + if common.RetryAvoidUsedChannelEnabled && retryParam.GetRetry() > 0 { + useChannelStrs := c.GetStringSlice("use_channel") + for _, idStr := range useChannelStrs { + if id, err := strconv.Atoi(idStr); err == nil { + retryParam.AddUsedChannel(id) + } + } + // 记录排除信息到日志 + if len(retryParam.UsedChannelIds) > 0 { + logger.LogInfo(c, fmt.Sprintf("重试排除了 %d 个已用渠道", len(retryParam.UsedChannelIds))) + } + } + channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam) info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info) @@ -290,7 +329,9 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } if channel == nil { - return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + // 该优先级的所有渠道都被排除,返回 nil 以便继续尝试下一个优先级 + // All channels at this priority have been excluded, return nil to continue trying next priority + return nil, nil } newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName) diff --git a/model/ability.go b/model/ability.go index 1d7c53fa5805..feaf683f101c 100644 --- a/model/ability.go +++ b/model/ability.go @@ -79,8 +79,13 @@ func getPriority(group string, model string, retry int) (int, error) { // 确定要使用的优先级 var priorityToUse int - if retry >= len(priorities) { - // 如果重试次数大于优先级数,则使用最小的优先级 + if common.RetryPriorityMode == "round-robin" && len(priorities) > 0 { + // 轮询模式:始终使用模运算循环 + // Round-robin mode: always use modulo operation to cycle through priorities + priorityToUse = priorities[retry%len(priorities)] + } else if retry >= len(priorities) { + // 顺序模式:如果重试次数大于优先级数,则使用最小的优先级 + // Sequential mode: if retry exceeds priority count, use lowest priority priorityToUse = priorities[len(priorities)-1] } else { priorityToUse = priorities[retry] @@ -103,7 +108,7 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { return channelQuery, nil } -func GetChannel(group string, model string, retry int) (*Channel, error) { +func GetChannel(group string, model string, retry int, excludeIds map[int]struct{}) (*Channel, error) { var abilities []Ability var err error = nil @@ -111,6 +116,16 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { if err != nil { return nil, err } + + // 如果有排除列表,添加 NOT IN 条件 + if len(excludeIds) > 0 { + var excludeIdList []int + for id := range excludeIds { + excludeIdList = append(excludeIdList, id) + } + channelQuery = channelQuery.Where("channel_id NOT IN ?", excludeIdList) + } + if common.UsingSQLite || common.UsingPostgreSQL { err = channelQuery.Order("weight DESC").Find(&abilities).Error } else { diff --git a/model/channel_cache.go b/model/channel_cache.go index c9c503576038..cd15204129b0 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -93,10 +93,10 @@ func SyncChannelCache(frequency int) { } } -func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { +func GetRandomSatisfiedChannel(group string, model string, retry int, excludeIds map[int]struct{}) (*Channel, error) { // if memory cache is disabled, get channel directly from database if !common.MemoryCacheEnabled { - return GetChannel(group, model, retry) + return GetChannel(group, model, retry, excludeIds) } channelSyncLock.RLock() @@ -117,6 +117,12 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, if len(channels) == 1 { if channel, ok := channelsIDM[channels[0]]; ok { + // 检查是否需要排除 + if len(excludeIds) > 0 { + if _, excluded := excludeIds[channels[0]]; excluded { + return nil, nil // 唯一渠道已被排除 + } + } return channel, nil } return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) @@ -136,7 +142,15 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, } sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities))) - if retry >= len(uniquePriorities) { + // 轮询模式:始终使用模运算来循环优先级 + // Round-robin mode: always use modulo operation to cycle through priorities + // 顺序模式:超出范围时使用最低优先级 + // Sequential mode: use lowest priority when out of range + if common.RetryPriorityMode == "round-robin" && len(uniquePriorities) > 0 { + // 轮询模式:始终使用模运算循环 + retry = retry % len(uniquePriorities) + } else if retry >= len(uniquePriorities) { + // 顺序模式:超出范围时使用最低优先级 retry = len(uniquePriorities) - 1 } targetPriority := int64(sortedUniquePriorities[retry]) @@ -147,6 +161,12 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, for _, channelId := range channels { if channel, ok := channelsIDM[channelId]; ok { if channel.GetPriority() == targetPriority { + // 如果需要排除且渠道在排除列表中,则跳过 + if len(excludeIds) > 0 { + if _, excluded := excludeIds[channelId]; excluded { + continue + } + } sumWeight += channel.GetWeight() targetChannels = append(targetChannels, channel) } @@ -156,7 +176,9 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, } if len(targetChannels) == 0 { - return nil, errors.New(fmt.Sprintf("no channel found, group: %s, model: %s, priority: %d", group, model, targetPriority)) + // 该优先级的所有渠道都被排除,返回 nil 以便尝试下一个优先级 + // All channels at this priority have been excluded, return nil to try next priority + return nil, nil } // smoothing factor and adjustment diff --git a/model/option.go b/model/option.go index 24cf7862df39..194af5244011 100644 --- a/model/option.go +++ b/model/option.go @@ -45,6 +45,8 @@ func InitOptionMap() { common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled) + common.OptionMap["RetryAvoidUsedChannelEnabled"] = strconv.FormatBool(common.RetryAvoidUsedChannelEnabled) + common.OptionMap["RetryPriorityMode"] = common.RetryPriorityMode common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) @@ -243,6 +245,8 @@ func updateOptionMap(key string, value string) (err error) { common.AutomaticDisableChannelEnabled = boolValue case "AutomaticEnableChannelEnabled": common.AutomaticEnableChannelEnabled = boolValue + case "RetryAvoidUsedChannelEnabled": + common.RetryAvoidUsedChannelEnabled = boolValue case "LogConsumeEnabled": common.LogConsumeEnabled = boolValue case "DisplayInCurrencyEnabled": @@ -407,6 +411,10 @@ func updateOptionMap(key string, value string) (err error) { err = setting.UpdateModelRequestRateLimitGroupByJSONString(value) case "RetryTimes": common.RetryTimes, _ = strconv.Atoi(value) + case "RetryPriorityMode": + if value == "sequential" || value == "round-robin" { + common.RetryPriorityMode = value + } case "DataExportInterval": common.DataExportInterval, _ = strconv.Atoi(value) case "DataExportDefaultTime": diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef8cec3..c1156a7888a7 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -12,11 +12,13 @@ import ( ) type RetryParam struct { - Ctx *gin.Context - TokenGroup string - ModelName string - Retry *int - resetNextTry bool + Ctx *gin.Context + TokenGroup string + ModelName string + Retry *int + resetNextTry bool + UsedChannelIds map[int]struct{} // 已使用的渠道ID集合 + CurrentPriorityIndex int // 当前使用的优先级索引 } func (p *RetryParam) GetRetry() int { @@ -45,6 +47,33 @@ func (p *RetryParam) ResetRetryNextTry() { p.resetNextTry = true } +// AddUsedChannel 添加已使用的渠道ID +func (p *RetryParam) AddUsedChannel(channelId int) { + if p.UsedChannelIds == nil { + p.UsedChannelIds = make(map[int]struct{}) + } + p.UsedChannelIds[channelId] = struct{}{} +} + +// IsChannelUsed 检查渠道是否已被使用 +func (p *RetryParam) IsChannelUsed(channelId int) bool { + if p.UsedChannelIds == nil { + return false + } + _, used := p.UsedChannelIds[channelId] + return used +} + +// IncreasePriorityIndex 增加优先级索引(切换到下一个优先级) +func (p *RetryParam) IncreasePriorityIndex() { + p.CurrentPriorityIndex++ +} + +// GetPriorityIndex 获取当前优先级索引 +func (p *RetryParam) GetPriorityIndex() int { + return p.CurrentPriorityIndex +} + // CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements. // 尝试获取一个满足要求的随机渠道。 // @@ -115,7 +144,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, } logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry) - channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry) + channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.UsedChannelIds) if channel == nil { // Current group has no available channel for this model, try next group // 当前分组没有该模型的可用渠道,尝试下一个分组 @@ -153,10 +182,22 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, break } } else { - channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry()) + // 在 round-robin 模式下,使用 retry 参数(会在 GetRandomSatisfiedChannel 中进行模运算) + // 在 sequential 模式下,使用 priorityIndex(只有当前优先级用尽时才会增加) + // In round-robin mode, use retry parameter (modulo operation in GetRandomSatisfiedChannel) + // In sequential mode, use priorityIndex (only increases when current priority is exhausted) + retryOrPriority := param.GetPriorityIndex() + if common.RetryPriorityMode == "round-robin" { + retryOrPriority = param.GetRetry() + } + channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, retryOrPriority, param.UsedChannelIds) if err != nil { return nil, param.TokenGroup, err } + // 如果 channel 为 nil 但没有错误,说明该优先级的所有渠道都被排除了 + // 返回 nil 以便外层循环继续尝试下一个优先级 + // If channel is nil but no error, it means all channels at this priority have been excluded + // Return nil to allow outer loop to try next priority } return channel, selectGroup, nil } diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 4a77bcf101d3..98dea7ef7338 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -69,6 +69,8 @@ const OperationSetting = () => { QuotaRemindThreshold: 0, AutomaticDisableChannelEnabled: false, AutomaticEnableChannelEnabled: false, + RetryAvoidUsedChannelEnabled: false, + RetryPriorityMode: 'sequential', AutomaticDisableKeywords: '', AutomaticDisableStatusCodes: '401', 'monitor_setting.auto_test_channel_enabled': false, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f6d55544d8ba..d3fb56558619 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1065,6 +1065,13 @@ "成功": "Success", "成功兑换额度:": "Successful redemption amount:", "成功时自动启用通道": "Enable channel when successful", + "重试时避开已尝试渠道": "Avoid used channels on retry", + "启用后,重试时不会再次选择已失败的渠道。注意:渠道数量较少时可能更早失败。": "When enabled, retries will not select channels that have already failed. Note: May fail earlier when there are fewer channels.", + "重试优先级模式": "Retry Priority Mode", + "选择重试模式": "Select retry mode", + "顺序:同一优先级内尝试所有渠道后才降级;轮询:每个优先级轮流尝试": "Sequential: Try all channels in same priority before downgrade; Round-robin: Rotate through priorities", + "分组顺序重试": "Sequential Retry", + "分组轮询重试": "Round-robin Retry", "我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "I have understood that disabling two-factor authentication will permanently delete all related settings and backup codes, this operation cannot be undone", "我已阅读并同意": "I have read and agree to", "或": "or", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e91f50a4eb2e..d62c7cb6fa66 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1053,6 +1053,13 @@ "成功": "成功", "成功兑换额度:": "成功兑换额度:", "成功时自动启用通道": "成功时自动启用通道", + "重试时避开已尝试渠道": "重试时避开已尝试渠道", + "启用后,重试时不会再次选择已失败的渠道。注意:渠道数量较少时可能更早失败。": "启用后,重试时不会再次选择已失败的渠道。注意:渠道数量较少时可能更早失败。", + "重试优先级模式": "重试优先级模式", + "选择重试模式": "选择重试模式", + "顺序:同一优先级内尝试所有渠道后才降级;轮询:每个优先级轮流尝试": "顺序:同一优先级内尝试所有渠道后才降级;轮询:每个优先级轮流尝试", + "分组顺序重试": "分组顺序重试", + "分组轮询重试": "分组轮询重试", "我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销", "我已阅读并同意": "我已阅读并同意", "或": "或", diff --git a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx index 9715ef3cba2f..eb053d9b7f94 100644 --- a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx +++ b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx @@ -47,6 +47,8 @@ export default function SettingsMonitoring(props) { QuotaRemindThreshold: '', AutomaticDisableChannelEnabled: false, AutomaticEnableChannelEnabled: false, + RetryAvoidUsedChannelEnabled: false, + RetryPriorityMode: 'sequential', AutomaticDisableKeywords: '', AutomaticDisableStatusCodes: '401', 'monitor_setting.auto_test_channel_enabled': false, @@ -230,6 +232,43 @@ export default function SettingsMonitoring(props) { } /> + + + setInputs({ + ...inputs, + RetryAvoidUsedChannelEnabled: value, + }) + } + /> + + + + setInputs({ + ...inputs, + RetryPriorityMode: value, + }) + } + > + + {t('分组顺序重试')} (A1→A2→A3→B1→B2→B3) + + + {t('分组轮询重试')} (A1→B1→C1→A2→B2→C2) + + +