This commit is contained in:
Memory
2026-08-31 10:17:04 +09:00
committed by GitHub
27 changed files with 1128 additions and 144 deletions
+9 -1
View File
@@ -21,6 +21,15 @@ const (
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
ContextKeyTokenAutoGroups ContextKey = "token_auto_groups"
// ContextKeyTokenRoutingPriority 令牌级智能路由策略:''=手动(指定分组/单一分组),
// auto/price/speed/success_rate = 系统按策略在所有可用分组中选择最优渠道。
ContextKeyTokenRoutingPriority ContextKey = "token_routing_priority"
// ContextKeyTokenGroupOrder 令牌级有序分组列表(openLUX group_ids 对齐),
// 值类型 []string,按优先级排列。设置后路由按列表逐组尝试,无渠道自动跳到下一组。
ContextKeyTokenGroupOrder ContextKey = "token_group_order"
// ContextKeyModelGroupOverride 请求级 model 后缀覆盖强制分组(openLUX 兼容,
// model 形如 "deepseek-chat@g2")。值类型 string,优先级高于令牌级智能路由。
ContextKeyModelGroupOverride ContextKey = "model_group_override"
/* channel related keys */
ContextKeyChannelId ContextKey = "channel_id"
@@ -42,7 +51,6 @@ const (
ContextKeyAutoGroup ContextKey = "auto_group"
ContextKeyAutoGroupIndex ContextKey = "auto_group_index"
ContextKeyAutoGroupRetryIndex ContextKey = "auto_group_retry_index"
/* user related keys */
ContextKeyUserId ContextKey = "id"
+25
View File
@@ -0,0 +1,25 @@
package constant
// 令牌级路由策略(Token.RoutingPriority),对应 openLUX 的「智能路由 vs 指定分组」二选一:
//
// - 空字符串(默认):手动路由。令牌按 group 指定的单一分组,或(group=auto 时)按
// AutoGroups 指定的分组优先级列表顺序路由 —— 即「指定分组」。
// - auto/price/speed/success_rate:智能路由。系统忽略令牌指定分组,在所有用户可用分组中
// 按策略自动排序并选择最优渠道。
const (
RoutingPriorityNone = "" // 手动:指定分组(单一分组 / 分组优先级列表)
RoutingPriorityAuto = "auto" // 智能路由:综合价格、速度、成功率自动排序
RoutingPriorityPrice = "price" // 智能路由:价格优先,选成本更低渠道
RoutingPrioritySpeed = "speed" // 智能路由:速度优先,选响应更快渠道
RoutingPrioritySuccessRate = "success_rate" // 智能路由:成功率优先,选近期更稳定渠道
)
// ValidRoutingPriority 校验路由策略取值。
func ValidRoutingPriority(priority string) bool {
switch priority {
case RoutingPriorityNone, RoutingPriorityAuto, RoutingPriorityPrice,
RoutingPrioritySpeed, RoutingPrioritySuccessRate:
return true
}
return false
}
+130 -8
View File
@@ -127,6 +127,57 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo
return true
}
// setTokenGroupOrder 校验并保存令牌级有序分组列表(openLUX group_ids 对齐)。
// validateGroupOrderRawDuplicate 校验原始 group_order 输入中的重复分组。
// GetGroupOrderList 会先去重,若在此前不校验,重复输入将静默通过。
func validateGroupOrderRawDuplicate(c *gin.Context, raw string) bool {
parts := strings.Split(raw, ",")
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": p})
return false
}
seen[p] = struct{}{}
}
return true
}
func setTokenGroupOrder(c *gin.Context, token *model.Token, groups []string) bool {
if len(groups) == 0 {
token.GroupOrder = ""
return true
}
maxCount := setting.GetMaxTokenAutoGroups()
if len(groups) > maxCount {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsTooMany, map[string]any{"Max": maxCount})
return false
}
userGroup, err := getTokenRequestUserGroup(c)
if err != nil {
common.ApiError(c, err)
return false
}
seen := make(map[string]struct{}, len(groups))
for _, group := range groups {
if _, ok := seen[group]; ok {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": group})
return false
}
seen[group] = struct{}{}
if !service.IsUserSelectableGroup(userGroup, group) {
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsInvalid, map[string]any{"Group": group})
return false
}
}
token.SetGroupOrderList(groups)
return true
}
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
@@ -310,13 +361,36 @@ func AddToken(c *gin.Context) {
})
return
}
if token.Group == "auto" {
// 二选一:智能路由 vs 指定分组(单分组 / 有序分组列表 / auto 分组)
if token.RoutingPriority != constant.RoutingPriorityNone {
// 智能路由:校验策略值,忽略指定分组,强制跨分组重试
if !constant.ValidRoutingPriority(token.RoutingPriority) {
common.ApiErrorI18n(c, i18n.MsgTokenRoutingPriorityInvalid)
return
}
token.Group = "auto"
token.CrossGroupRetry = true
_ = token.SetAutoGroups(nil)
token.GroupOrder = ""
} else if token.GroupOrder != "" {
// 有序分组列表(openLUX group_ids):校验并按序保存
if !validateGroupOrderRawDuplicate(c, token.GroupOrder) {
return
}
if !setTokenGroupOrder(c, &token, token.GetGroupOrderList()) {
return
}
_ = token.SetAutoGroups(nil)
token.ManualGroup = ""
} else if token.Group == "auto" {
if !setTokenAutoGroups(c, &token, request.AutoGroups.Groups) {
return
}
token.ManualGroup = ""
} else {
token.CrossGroupRetry = false
_ = token.SetAutoGroups(nil)
token.ManualGroup = ""
}
key, err := common.GenerateKey()
if err != nil {
@@ -339,6 +413,9 @@ func AddToken(c *gin.Context) {
Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry,
AutoGroups: token.AutoGroups,
RoutingPriority: token.RoutingPriority,
ManualGroup: token.ManualGroup,
GroupOrder: token.GroupOrder,
}
err = cleanToken.Insert()
if err != nil {
@@ -416,15 +493,60 @@ func UpdateToken(c *gin.Context) {
cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled
cleanToken.ModelLimits = token.ModelLimits
cleanToken.AllowIps = token.AllowIps
cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry
if token.Group != "auto" {
cleanToken.CrossGroupRetry = false
_ = cleanToken.SetAutoGroups(nil)
} else if request.AutoGroups.Set {
if !setTokenAutoGroups(c, cleanToken, request.AutoGroups.Groups) {
// 二选一:智能路由 vs 指定分组(单分组 / 有序分组列表 / auto 分组)
if token.RoutingPriority != constant.RoutingPriorityNone {
// 智能路由:校验策略值,忽略指定分组,强制跨分组重试
if !constant.ValidRoutingPriority(token.RoutingPriority) {
common.ApiErrorI18n(c, i18n.MsgTokenRoutingPriorityInvalid)
return
}
cleanToken.RoutingPriority = token.RoutingPriority
// 切到智能路由前记住手动分组,供切回时恢复。
// 前端开启开关时提交 manual_group(原分组);未带时回退到当前非 auto 分组。
if token.ManualGroup != "" {
cleanToken.ManualGroup = token.ManualGroup
} else if cleanToken.ManualGroup == "" && cleanToken.Group != "auto" {
cleanToken.ManualGroup = cleanToken.Group
}
cleanToken.Group = "auto"
cleanToken.CrossGroupRetry = true
_ = cleanToken.SetAutoGroups(nil)
cleanToken.GroupOrder = ""
} else if token.GroupOrder != "" {
// 有序分组列表(openLUX group_ids):校验并按序保存
if !validateGroupOrderRawDuplicate(c, token.GroupOrder) {
return
}
if !setTokenGroupOrder(c, cleanToken, token.GetGroupOrderList()) {
return
}
cleanToken.RoutingPriority = ""
cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = true
_ = cleanToken.SetAutoGroups(nil)
cleanToken.ManualGroup = ""
} else {
cleanToken.RoutingPriority = ""
cleanToken.GroupOrder = ""
cleanToken.Group = token.Group
// 从智能路由切回手动:前端应提交恢复后的原分组(group);
// 兜底:若仍为 "auto"/空,则用此前记录的 manual_group 恢复。
if token.Group == "auto" || token.Group == "" {
if cleanToken.ManualGroup != "" {
cleanToken.Group = cleanToken.ManualGroup
}
}
cleanToken.CrossGroupRetry = token.CrossGroupRetry
if cleanToken.Group != "auto" {
cleanToken.CrossGroupRetry = false
_ = cleanToken.SetAutoGroups(nil)
} else if request.AutoGroups.Set {
if !setTokenAutoGroups(c, cleanToken, request.AutoGroups.Groups) {
return
}
}
// 已回到手动模式,清空保留的手动分组
cleanToken.ManualGroup = ""
}
}
err = cleanToken.Update()
+1
View File
@@ -58,6 +58,7 @@ const (
MsgTokenAutoGroupsTooMany = "token.auto_groups_too_many"
MsgTokenAutoGroupsDuplicate = "token.auto_groups_duplicate"
MsgTokenAutoGroupsInvalid = "token.auto_groups_invalid"
MsgTokenRoutingPriorityInvalid = "token.routing_priority_invalid"
)
// Redemption related messages
+1
View File
@@ -50,6 +50,7 @@ token.db_error: "Invalid token, database query error, please contact administrat
token.auto_groups_too_many: "A token can select at most {{.Max}} Auto groups"
token.auto_groups_duplicate: "Auto group {{.Group}} is duplicated"
token.auto_groups_invalid: "Auto group {{.Group}} is unavailable or unauthorized"
token.routing_priority_invalid: "Invalid routing priority; supported values: auto / price / speed / success_rate"
# Redemption messages
redemption.name_length: "Redemption code name length must be between 1-20"
+1
View File
@@ -51,6 +51,7 @@ token.db_error: "无效的令牌,数据库查询出错,请联系管理员"
token.auto_groups_too_many: "每个令牌最多可选择 {{.Max}} 个 Auto 分组"
token.auto_groups_duplicate: "Auto 分组 {{.Group}} 重复"
token.auto_groups_invalid: "Auto 分组 {{.Group}} 不可用或无权访问"
token.routing_priority_invalid: "路由策略无效,仅支持空值 / auto / price / speed / success_rate"
# Redemption messages
redemption.name_length: "兑换码名称长度必须在1-20之间"
+1
View File
@@ -51,6 +51,7 @@ token.db_error: "無效的令牌,資料庫查詢出錯,請聯繫管理員"
token.auto_groups_too_many: "每個令牌最多可選擇 {{.Max}} 個 Auto 分組"
token.auto_groups_duplicate: "Auto 分組 {{.Group}} 重複"
token.auto_groups_invalid: "Auto 分組 {{.Group}} 不可用或無權存取"
token.routing_priority_invalid: "路由策略無效,僅支援空值 / auto / price / speed / success_rate"
# Redemption messages
redemption.name_length: "兌換碼名稱長度必須在1-20之間"
+4
View File
@@ -505,6 +505,10 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
}
common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group)
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry)
// 令牌级智能路由策略(''=手动指定分组/单一分组;auto/price/speed/success_rate=智能路由)
common.SetContextKey(c, constant.ContextKeyTokenRoutingPriority, token.RoutingPriority)
// 令牌级有序分组列表(openLUX group_ids 对齐):值 []string,按优先级排列
common.SetContextKey(c, constant.ContextKeyTokenGroupOrder, token.GetGroupOrderList())
if token.AutoGroups != "" {
autoGroups, err := token.GetAutoGroups()
if err != nil {
+38 -3
View File
@@ -31,6 +31,13 @@ type ModelRequest struct {
Group string `json:"group,omitempty"`
}
// isRequestModelGroupForced 报告当前请求是否通过 model@g2 后缀强制指定了路由分组。
// 强制分组时跳过 affinity 选区,让 CacheGetRandomSatisfiedChannel 的强制分组分支接管。
func isRequestModelGroupForced(c *gin.Context) bool {
_, ok := common.GetContextKey(c, constant.ContextKeyModelGroupOverride)
return ok
}
func Distribute() func(c *gin.Context) {
return func(c *gin.Context) {
var channel *model.Channel
@@ -45,6 +52,9 @@ func Distribute() func(c *gin.Context) {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
return
}
// openLUX 兼容:请求级 model 后缀覆盖路由分组,例如 "deepseek-chat@g2"。
// 后缀必须是用户可用分组才生效;剥离后的基础模型名用于上游调用、计费与重试。
modelRequest.Model = applyModelGroupSuffix(c, modelRequest.Model)
if pin, found, overridden := constraints.ResolvedPin(); found {
for _, lost := range overridden {
logger.LogWarn(c, fmt.Sprintf(
@@ -124,7 +134,7 @@ func Distribute() func(c *gin.Context) {
}
}
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found && !isRequestModelGroupForced(c) {
affinityUsable := false
preferred, err := model.CacheGetChannel(preferredChannelID)
affinitySatisfied := false
@@ -198,8 +208,12 @@ func Distribute() func(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
SetupContextForSelectedChannel(c, channel, modelRequest.Model)
c.Next()
if channel != nil && c.Writer != nil && c.Writer.Status() < http.StatusBadRequest {
service.RecordChannelAffinity(c, channel.Id)
if channel != nil && c.Writer != nil {
// 智能路由 success_rate 数据源:按最终 HTTP 状态记录渠道请求成败。
model.CacheRecordChannelResult(channel.Id, c.Writer.Status() < http.StatusBadRequest)
if c.Writer.Status() < http.StatusBadRequest {
service.RecordChannelAffinity(c, channel.Id)
}
}
}
}
@@ -232,6 +246,27 @@ func channelMatchesExpectedTaskPlugin(c *gin.Context, channel *model.Channel, ex
return ok && plugin == pinned.Plugin
}
// applyModelGroupSuffix 解析 "model@g2" 形式的模型后缀覆盖(openLUX 兼容)。
// 仅当 @ 后的后缀是当前用户可用分组时生效:把强制分组写入上下文(ContextKeyModelGroupOverride),
// 返回剥离后缀的基础模型名。否则原样返回模型名,不改变路由(避免误伤不含分组语义的模型名)。
func applyModelGroupSuffix(c *gin.Context, modelName string) string {
if modelName == "" {
return modelName
}
idx := strings.LastIndex(modelName, "@")
if idx <= 0 || idx == len(modelName)-1 {
return modelName
}
base := modelName[:idx]
group := modelName[idx+1:]
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
if !service.GroupInUserUsableGroups(userGroup, group) {
return modelName
}
common.SetContextKey(c, constant.ContextKeyModelGroupOverride, group)
return base
}
func pinnedEndpointCandidateForChannel(c *gin.Context, channel *model.Channel, expected string) (jsplugin.ProtocolBinding, bool) {
if c == nil || channel == nil || expected == "" {
return jsplugin.ProtocolBinding{}, false
+2
View File
@@ -32,6 +32,8 @@ type Channel struct {
CreatedTime int64 `json:"created_time" gorm:"bigint"`
TestTime int64 `json:"test_time" gorm:"bigint"`
ResponseTime int `json:"response_time"` // in milliseconds
RequestCount int64 `json:"request_count" gorm:"bigint;default:0"` // 请求总数(智能路由 success_rate 数据源)
SuccessCount int64 `json:"success_count" gorm:"bigint;default:0"` // 成功请求数(智能路由 success_rate 数据源)
BaseURL *string `json:"base_url" gorm:"column:base_url;default:''"`
Other string `json:"other"`
Balance float64 `json:"balance"` // in USD
+240
View File
@@ -82,6 +82,20 @@ func InitChannelCache() {
group2model2channels = newGroup2model2channels
//channelsIDM = newChannelId2channel
for i, channel := range newChannelId2channel {
if oldChannel, ok := channelsIDM[i]; ok {
// 保留内存中的响应时间:DB 重建会把 response_time 覆盖为旧值/0
// 而智能路由 speed/success_rate 策略依赖实时采集值(见 flushChannelResponseTimes)。
if oldChannel.ResponseTime > 0 {
channel.ResponseTime = oldChannel.ResponseTime
}
// 同样保留内存中的请求/成功计数(可能含未落库的增量),避免周期同步回退。
if oldChannel.RequestCount > channel.RequestCount {
channel.RequestCount = oldChannel.RequestCount
}
if oldChannel.SuccessCount > channel.SuccessCount {
channel.SuccessCount = oldChannel.SuccessCount
}
}
if channel.ChannelInfo.IsMultiKey {
channel.Keys = channel.GetKeys()
if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
@@ -216,6 +230,87 @@ func GetRandomSatisfiedChannel(
return nil, errors.New("channel not found")
}
// GroupChannelStat 某分组下指定模型候选渠道的统计信息,供智能路由按策略跨分组排序。
type GroupChannelStat struct {
Group string
HasChannel bool // 该分组是否有该模型的可用渠道
MaxPriority int64 // 候选渠道中最高优先级
MinResponseTime int // 候选渠道中最短响应时间(毫秒),0 表示无记录
SumWeight int // 候选渠道权重和
TotalRequest int64 // 候选渠道累计请求数(success_rate 数据源)
TotalSuccess int64 // 候选渠道累计成功数(success_rate 数据源)
SuccessRate float64 // 候选渠道综合成功率 = TotalSuccess/TotalRequest,无记录为 0
}
// GetGroupChannelStats 计算多个分组下指定模型的候选渠道统计。
// 智能路由(Token.RoutingPriority)用它来确定各分组的可用性与速度/成功率排序依据。
func GetGroupChannelStats(groups []string, model string, requestPath string) map[string]*GroupChannelStat {
stats := make(map[string]*GroupChannelStat, len(groups))
for _, group := range groups {
stats[group] = &GroupChannelStat{Group: group}
}
if len(groups) == 0 {
return stats
}
// 未启用内存缓存时退化为数据库查询
if !common.MemoryCacheEnabled {
filters := []dto.ChannelFilter{{Kind: dto.FilterRequestPath, RequestPath: requestPath}}
for _, group := range groups {
ch, _ := GetRandomSatisfiedChannel(group, model, 0, filters)
if ch == nil {
continue
}
st := stats[group]
st.HasChannel = true
st.MaxPriority = ch.GetPriority()
st.MinResponseTime = ch.ResponseTime
st.SumWeight = ch.GetWeight()
st.TotalRequest = ch.RequestCount
st.TotalSuccess = ch.SuccessCount
if st.TotalRequest > 0 {
st.SuccessRate = float64(st.TotalSuccess) / float64(st.TotalRequest)
}
}
return stats
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
normalized := ratio_setting.FormatMatchingModelName(model)
requestPathFilter := []dto.ChannelFilter{{Kind: dto.FilterRequestPath, RequestPath: requestPath}}
for _, group := range groups {
st := stats[group]
ids, _ := filterCandidateIDs(group2model2channels[group][model], model, requestPathFilter)
if len(ids) == 0 && normalized != "" && normalized != model {
ids, _ = filterCandidateIDs(group2model2channels[group][normalized], model, requestPathFilter)
}
if len(ids) == 0 {
continue
}
st.HasChannel = true
for _, id := range ids {
ch, ok := channelsIDM[id]
if !ok {
continue
}
if ch.GetPriority() > st.MaxPriority {
st.MaxPriority = ch.GetPriority()
}
if ch.ResponseTime > 0 && (st.MinResponseTime == 0 || ch.ResponseTime < st.MinResponseTime) {
st.MinResponseTime = ch.ResponseTime
}
st.SumWeight += ch.GetWeight()
st.TotalRequest += ch.RequestCount
st.TotalSuccess += ch.SuccessCount
}
if st.TotalRequest > 0 {
st.SuccessRate = float64(st.TotalSuccess) / float64(st.TotalRequest)
}
}
return stats
}
func CacheGetChannel(id int) (*Channel, error) {
if !common.MemoryCacheEnabled {
return GetChannelById(id, true)
@@ -307,3 +402,148 @@ func CacheUpdateChannel(channel *Channel) {
channelSyncLock.Unlock()
InvalidatePricingCache()
}
// ---------------------------------------------------------------------------
// 渠道响应时间采集(智能路由 speed/success_rate 策略的数据源)
//
// relay 成功结算(service.PostTextConsumeQuota)时调用 CacheUpdateChannelResponseTime
// 更新内存缓存,并由后台协程定期 flush 到数据库。注意与 model/utils.go 的批量更新器
// 区分:那是 delta 累加(+=),而响应时间是 set 语义(覆盖),故单独维护 dirty map。
// ---------------------------------------------------------------------------
var channelResponseTimeDirty = make(map[int]int)
var channelResponseTimeLock sync.Mutex
var responseTimeFlusherOnce sync.Once
// CacheUpdateChannelResponseTime 记录渠道最近一次成功请求的响应时间(毫秒)。
// 先覆盖内存缓存(智能路由读它),再进 dirty map 等待落库。
func CacheUpdateChannelResponseTime(channelId int, ms int) {
if channelId <= 0 || ms <= 0 {
return
}
channelSyncLock.Lock()
if channel, ok := channelsIDM[channelId]; ok {
channel.ResponseTime = ms
}
channelSyncLock.Unlock()
channelResponseTimeLock.Lock()
channelResponseTimeDirty[channelId] = ms
channelResponseTimeLock.Unlock()
startResponseTimeFlusher()
}
// flushChannelResponseTimes 把 dirty map 中的响应时间按 set 语义写回数据库。
func flushChannelResponseTimes() {
channelResponseTimeLock.Lock()
if len(channelResponseTimeDirty) == 0 {
channelResponseTimeLock.Unlock()
return
}
dirty := channelResponseTimeDirty
channelResponseTimeDirty = make(map[int]int)
channelResponseTimeLock.Unlock()
for channelId, ms := range dirty {
if err := DB.Model(&Channel{}).Where("id = ?", channelId).UpdateColumn("response_time", ms).Error; err != nil {
common.SysLog("failed to update channel response time: " + err.Error())
}
}
}
// startResponseTimeFlusher 懒启动后台刷新协程(首次采集时触发)。
func startResponseTimeFlusher() {
responseTimeFlusherOnce.Do(func() {
interval := common.BatchUpdateInterval
if interval <= 0 {
interval = 10
}
go func() {
for {
time.Sleep(time.Duration(interval) * time.Second)
flushChannelResponseTimes()
}
}()
})
}
// ---------------------------------------------------------------------------
// 渠道请求/成功计数(智能路由 success_rate 策略的数据源)
// 与 ResponseTime 相同的 dirty-map + flusher 模式,但这里是 delta 累加语义:
// 内存缓存存累计值(含未落库增量),dirty map 存 delta,后台协程定期累加写回 DB。
// ---------------------------------------------------------------------------
// ChannelCounter 一次未落库的计数增量。
type ChannelCounter struct {
Request int64
Success int64
}
var channelCountDirty = make(map[int]*ChannelCounter)
var channelCountLock sync.Mutex
var countFlusherOnce sync.Once
// CacheRecordChannelResult 记录一次渠道请求的成败(成功 = 结算前 HTTP 状态 < 400)。
// 先累加内存缓存(智能路由读它),再进 dirty map 等待落库。
func CacheRecordChannelResult(channelId int, success bool) {
if channelId <= 0 {
return
}
channelSyncLock.Lock()
if channel, ok := channelsIDM[channelId]; ok {
channel.RequestCount++
if success {
channel.SuccessCount++
}
}
channelSyncLock.Unlock()
channelCountLock.Lock()
d := channelCountDirty[channelId]
if d == nil {
d = &ChannelCounter{}
channelCountDirty[channelId] = d
}
d.Request++
if success {
d.Success++
}
channelCountLock.Unlock()
startCountFlusher()
}
// flushChannelCounts 把 dirty map 中的计数增量累加写回数据库。
func flushChannelCounts() {
channelCountLock.Lock()
if len(channelCountDirty) == 0 {
channelCountLock.Unlock()
return
}
dirty := channelCountDirty
channelCountDirty = make(map[int]*ChannelCounter)
channelCountLock.Unlock()
for channelId, d := range dirty {
if err := DB.Exec("UPDATE channels SET request_count = request_count + ?, success_count = success_count + ? WHERE id = ?", d.Request, d.Success, channelId).Error; err != nil {
common.SysLog("failed to update channel counts: " + err.Error())
}
}
}
// startCountFlusher 懒启动后台刷新协程(首次记录时触发)。
func startCountFlusher() {
countFlusherOnce.Do(func() {
interval := common.BatchUpdateInterval
if interval <= 0 {
interval = 10
}
go func() {
for {
time.Sleep(time.Duration(interval) * time.Second)
flushChannelCounts()
}
}()
})
}
+46 -2
View File
@@ -29,7 +29,23 @@ type Token struct {
Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
AutoGroups string `json:"-" gorm:"type:text"`
DeletedAt gorm.DeletedAt `gorm:"index"`
// RoutingPriority 令牌级路由策略(二选一):
// '' = 手动:按 group(单一分组)或 AutoGroups(分组优先级列表)路由 —— 指定分组
// auto/price/speed/success_rate = 智能路由:系统按策略在所有可用分组中选择最优渠道
RoutingPriority string `json:"routing_priority" gorm:"default:''"`
// ManualGroup 开启智能路由前的手动分组,切回手动时恢复 group 使用。
// 前端二选一:路由优先智能时 group 强制为 "auto",原分组记到这里。
ManualGroup string `json:"manual_group" gorm:"default:''"`
// GroupOrder 令牌级有序分组列表(openLUX group_ids 对齐),逗号分隔,按优先级排列。
// 设置后路由按列表顺序逐组尝试(无渠道的分组自动跳到下一组),忽略单分组 group。
// 与智能路由(RoutingPriority)互斥:智能路由优先;未设智能路由时列表优先生效。
GroupOrder string `json:"group_order" gorm:"default:''"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// IsSmartRouting 该令牌是否启用智能路由(区别于指定分组/单一分组的手动路由)。
func (token *Token) IsSmartRouting() bool {
return token.RoutingPriority != ""
}
func (token *Token) GetAutoGroups() ([]string, error) {
@@ -56,6 +72,33 @@ func (token *Token) SetAutoGroups(groups []string) error {
return nil
}
// GetGroupOrderList 解析逗号分隔的有序分组列表,去重、去空、保序。
func (token *Token) GetGroupOrderList() []string {
if token.GroupOrder == "" {
return nil
}
parts := strings.Split(token.GroupOrder, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
// SetGroupOrderList 设置有序分组列表(覆盖写入)。
func (token *Token) SetGroupOrderList(groups []string) {
token.GroupOrder = strings.Join(groups, ",")
}
func (token *Token) Clean() {
token.Key = ""
}
@@ -313,7 +356,8 @@ func (token *Token) Update() (err error) {
common.SysLog("failed to invalidate token cache before update: " + cacheErr.Error())
}
return DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
"model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "auto_groups").Updates(token).Error
"model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "auto_groups",
"routing_priority", "manual_group", "group_order").Updates(token).Error
}
func (token *Token) SelectUpdate() (err error) {
+5 -3
View File
@@ -66,7 +66,7 @@ if redis.call('EXISTS', KEYS[2]) == 1 then
return 0
end
if redis.call('EXISTS', KEYS[1]) == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[17])
redis.call('EXPIRE', KEYS[1], ARGV[20])
return 2
end
redis.call('HSET', KEYS[1],
@@ -74,8 +74,9 @@ redis.call('HSET', KEYS[1],
'CreatedTime', ARGV[5], 'AccessedTime', ARGV[6], 'ExpiredTime', ARGV[7],
'UnlimitedQuota', ARGV[8], 'ModelLimitsEnabled', ARGV[9], 'ModelLimits', ARGV[10],
'AllowIps', ARGV[11], 'Group', ARGV[12], 'CrossGroupRetry', ARGV[13],
'AutoGroups', ARGV[14], 'RemainQuota', ARGV[15], 'UsedQuota', ARGV[16])
redis.call('EXPIRE', KEYS[1], ARGV[17])
'AutoGroups', ARGV[14], 'RemainQuota', ARGV[15], 'UsedQuota', ARGV[16],
'RoutingPriority', ARGV[17], 'ManualGroup', ARGV[18], 'GroupOrder', ARGV[19])
redis.call('EXPIRE', KEYS[1], ARGV[20])
return 1`
return common.RDB.Eval(context.Background(), script, []string{
@@ -86,6 +87,7 @@ return 1`
strconv.FormatBool(token.UnlimitedQuota), strconv.FormatBool(token.ModelLimitsEnabled),
token.ModelLimits, allowIps, token.Group, strconv.FormatBool(token.CrossGroupRetry),
token.AutoGroups, token.RemainQuota, token.UsedQuota,
token.RoutingPriority, token.ManualGroup, token.GroupOrder,
tokenCacheTTLSeconds(),
).Int()
}
+158 -100
View File
@@ -2,6 +2,8 @@ package service
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
@@ -76,35 +78,28 @@ func (p *RetryParam) ResetRetryNextTry() {
// For "auto" tokenGroup with cross-group Retry enabled:
// 对于启用了跨分组重试的 "auto" tokenGroup
//
// - Each group will exhaust all its priorities before moving to the next group.
// 每个分组会用完所有优先级后才会切换到下一个分组。
// - Uses ContextKeyAutoGroupIndex to track the current group index, so each retry
// resumes from the group the previous attempt selected.
// 使用 ContextKeyAutoGroupIndex 跟踪当前分组索引,每次重试从上一轮选中的分组继续。
//
// - Uses ContextKeyAutoGroupIndex to track current group index.
// 使用 ContextKeyAutoGroupIndex 跟踪当前分组索引。
// - priorityRetry (当前分组内的优先级级别) is the current global retry count while
// staying in one group. A group is abandoned (switch to next group) when
// cross-group retry is on and priorityRetry >= common.RetryTimes.
// priorityRetry 是停留在同一分组时的全局重试计数;当跨分组重试开启且
// priorityRetry >= common.RetryTimes 时,该分组被放弃,切换到下一个分组。
//
// - Uses ContextKeyAutoGroupRetryIndex to track the global Retry count when current group started.
// 使用 ContextKeyAutoGroupRetryIndex 跟踪当前分组开始时的全局重试次数。
// - NOTE: with common.RetryTimes == 0 (option not configured), the condition above
// is always true, so every group gets exactly one priority tier attempted before
// switching. Cross-group traversal still works; lower priority tiers within a
// group are only reached when RetryTimes is large enough.
// 注意:当 common.RetryTimes == 0(未配置该选项)时,上述条件恒为真,每个分组
// 只会被尝试一个优先级档位就切换。跨分组遍历仍正常;分组内更低的优先级档位
// 只有在 RetryTimes 足够大时才会被尝试。
//
// - priorityRetry = Retry - startRetryIndex, represents the priority level within current group.
// priorityRetry = Retry - startRetryIndex,表示当前分组内的优先级级别。
//
// - When GetRandomSatisfiedChannel returns nil (priorities exhausted), moves to next group.
// 当 GetRandomSatisfiedChannel 返回 nil(优先级用完)时,切换到下一个分组。
//
// Example flow (2 groups, each with 2 priorities, RetryTimes=3):
// 示例流程(2个分组,每个有2个优先级,RetryTimes=3):
//
// Retry=0: GroupA, priority0 (startRetryIndex=0, priorityRetry=0)
// 分组A, 优先级0
//
// Retry=1: GroupA, priority1 (startRetryIndex=0, priorityRetry=1)
// 分组A, 优先级1
//
// Retry=2: GroupA exhausted → GroupB, priority0 (startRetryIndex=2, priorityRetry=0)
// 分组A用完 → 分组B, 优先级0
//
// Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1)
// 分组B, 优先级1
// - When GetRandomSatisfiedChannel returns nil (no channel for this model), moves
// to next group in the same invocation, regardless of cross-group retry flag.
// 当 GetRandomSatisfiedChannel 返回 nil(该模型无可用渠道)时,跳过该分组,
// 在同一轮调用内继续尝试下一个分组。
func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) {
var channel *model.Channel
var err error
@@ -112,87 +107,58 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup)
filters := GetChannelConstraints(param.Ctx).Filters
// 请求级 model 后缀覆盖(openLUX 兼容):请求体 model 形如 "deepseek-chat@g2" 时
// 强制路由到分组 g2。优先级最高,覆盖令牌级智能路由与指定分组。
if forcedGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyModelGroupOverride); forcedGroup != "" {
channel, err = model.GetRandomSatisfiedChannel(forcedGroup, param.ModelName, param.GetRetry(), filters)
if err != nil {
return nil, selectGroup, err
}
common.SysLog(fmt.Sprintf("smart routing: strategy=model_suffix_override model=%s userGroup=%s forcedGroup=%s", param.ModelName, userGroup, forcedGroup))
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, forcedGroup)
return channel, forcedGroup, nil
}
// 智能路由:令牌设置了 RoutingPriority 时,忽略其指定分组,按策略在
// 所有可用分组中选择最优渠道。跨分组重试强制开启,保证可切换分组。
routingPriority := common.GetContextKeyString(param.Ctx, constant.ContextKeyTokenRoutingPriority)
if routingPriority != "" {
// 请求级覆盖:请求体 provider.sort 可临时切换智能路由策略(openLUX 兼容)。
if override := getRequestRoutingOverride(param.Ctx); override != "" {
routingPriority = override
}
smartGroups := GetSmartRoutingGroups(userGroup, param.ModelName, param.RequestPath, routingPriority)
if len(smartGroups) == 0 {
return nil, selectGroup, errors.New("smart routing: no usable group has channel for this model")
}
common.SysLog(fmt.Sprintf("smart routing: strategy=%s model=%s userGroup=%s smartGroups=%v", routingPriority, param.ModelName, userGroup, smartGroups))
return selectFromOrderedGroups(param, smartGroups, true)
}
// 令牌级有序分组列表(openLUX group_ids 对齐):按列表顺序逐组尝试,
// 无渠道的分组自动跳到下一组。未设智能路由时优先生效,忽略单分组 group。
if groupOrder, ok := common.GetContextKey(param.Ctx, constant.ContextKeyTokenGroupOrder); ok {
if groups, ok := groupOrder.([]string); ok && len(groups) > 0 {
filtered := FilterUserTokenAutoGroups(userGroup, groups)
if len(filtered) > 0 {
common.SysLog(fmt.Sprintf("smart routing: strategy=manual_group_order model=%s userGroup=%s groups=%v", param.ModelName, userGroup, filtered))
return selectFromOrderedGroups(param, filtered, true)
}
}
}
if param.TokenGroup == "auto" {
autoGroups := GetRequestAutoGroups(param.Ctx, userGroup)
if len(autoGroups) == 0 {
return nil, selectGroup, errors.New("auto groups is not enabled")
}
// startGroupIndex: the group index to start searching from
// startGroupIndex: 开始搜索的分组索引
startGroupIndex := 0
crossGroupRetry := common.GetContextKeyBool(param.Ctx, constant.ContextKeyTokenCrossGroupRetry)
return selectFromOrderedGroups(param, autoGroups, crossGroupRetry)
}
if lastGroupIndex, exists := common.GetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex); exists {
if idx, ok := lastGroupIndex.(int); ok {
startGroupIndex = idx
}
}
for i := startGroupIndex; i < len(autoGroups); i++ {
autoGroup := autoGroups[i]
// Calculate priorityRetry for current group
// 计算当前分组的 priorityRetry
priorityRetry := param.GetRetry()
// If moved to a new group, reset priorityRetry and update startRetryIndex
// 如果切换到新分组,重置 priorityRetry 并更新 startRetryIndex
if i > startGroupIndex {
priorityRetry = 0
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
channel, _ = model.GetRandomSatisfiedChannel(
autoGroup,
param.ModelName,
priorityRetry,
filters,
)
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
logger.LogDebug(param.Ctx, "No available channel in group %s for model %s at priorityRetry %d, trying next group", autoGroup, param.ModelName, priorityRetry)
// 重置状态以尝试下一个分组
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, 0)
// Reset retry counter so outer loop can continue for next group
// 重置重试计数器,以便外层循环可以为下一个分组继续
param.SetRetry(0)
continue
}
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, autoGroup)
selectGroup = autoGroup
logger.LogDebug(param.Ctx, "Auto selected group: %s", autoGroup)
// Prepare state for next retry
// 为下一次重试准备状态
if crossGroupRetry && priorityRetry >= common.RetryTimes {
// Current group has exhausted all retries, prepare to switch to next group
// This request still uses current group, but next retry will use next group
// 当前分组已用完所有重试次数,准备切换到下一个分组
// 本次请求仍使用当前分组,但下次重试将使用下一个分组
logger.LogDebug(param.Ctx, "Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry", autoGroup, priorityRetry, common.RetryTimes)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
// Reset retry counter so outer loop can continue for next group
// 重置重试计数器,以便外层循环可以为下一个分组继续
param.SetRetry(0)
param.ResetRetryNextTry()
} else {
// Stay in current group, save current state
// 保持在当前分组,保存当前状态
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i)
}
break
}
} else {
channel, err = model.GetRandomSatisfiedChannel(
param.TokenGroup,
param.ModelName,
param.GetRetry(),
filters,
)
if err != nil {
return nil, param.TokenGroup, err
}
channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), filters)
if err != nil {
return nil, param.TokenGroup, err
}
return channel, selectGroup, nil
}
@@ -249,3 +215,95 @@ func pinnedTaskPluginChannelTypes(c *gin.Context, expected string) []int {
}
return channelTypes
}
// selectFromOrderedGroups 按有序分组列表逐组选择渠道,每组用完全部优先级后
// 才切换到下一组,并维护跨分组重试所需的上下文状态。
// orderedGroups 可为:指定分组(AutoGroups 列表)或智能路由算出的最优分组序。
func selectFromOrderedGroups(param *RetryParam, orderedGroups []string, crossGroupRetry bool) (*model.Channel, string, error) {
selectGroup := param.TokenGroup
filters := GetChannelConstraints(param.Ctx).Filters
// startGroupIndex: the group index to start searching from
// startGroupIndex: 开始搜索的分组索引
startGroupIndex := 0
if lastGroupIndex, exists := common.GetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex); exists {
if idx, ok := lastGroupIndex.(int); ok {
startGroupIndex = idx
}
}
for i := startGroupIndex; i < len(orderedGroups); i++ {
autoGroup := orderedGroups[i]
// Calculate priorityRetry for current group
// 计算当前分组的 priorityRetry
priorityRetry := param.GetRetry()
// If moved to a new group, reset priorityRetry and update startRetryIndex
// 如果切换到新分组,重置 priorityRetry 并更新 startRetryIndex
if i > startGroupIndex {
priorityRetry = 0
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
channel, _ := model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, filters)
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
logger.LogDebug(param.Ctx, "No available channel in group %s for model %s at priorityRetry %d, trying next group", autoGroup, param.ModelName, priorityRetry)
// 重置状态以尝试下一个分组
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
// Reset retry counter so outer loop can continue for next group
// 重置重试计数器,以便外层循环可以为下一个分组继续
param.SetRetry(0)
continue
}
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, autoGroup)
selectGroup = autoGroup
logger.LogDebug(param.Ctx, "Auto selected group: %s", autoGroup)
// Prepare state for next retry
// 为下一次重试准备状态
if crossGroupRetry && priorityRetry >= common.RetryTimes {
// Current group has exhausted all retries, prepare to switch to next group
// This request still uses current group, but next retry will use next group
// 当前分组已用完所有重试次数,准备切换到下一个分组
// 本次请求仍使用当前分组,但下次重试将使用下一个分组
logger.LogDebug(param.Ctx, "Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry", autoGroup, priorityRetry, common.RetryTimes)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
// Reset retry counter so outer loop can continue for next group
// 重置重试计数器,以便外层循环可以为下一个分组继续
param.SetRetry(0)
param.ResetRetryNextTry()
} else {
// Stay in current group, save current state
// 保持在当前分组,保存当前状态
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i)
}
return channel, selectGroup, nil
}
return nil, selectGroup, nil
}
// getRequestRoutingOverride 读取请求体里的 provider.sort,返回合法策略名或空串。
// openLUX 兼容:请求级覆盖令牌级智能路由策略。
func getRequestRoutingOverride(c *gin.Context) string {
if !strings.HasPrefix(c.Request.Header.Get("Content-Type"), "application/json") {
return ""
}
var req struct {
Provider *struct {
Sort string `json:"sort"`
} `json:"provider"`
}
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return ""
}
if req.Provider == nil {
return ""
}
s := strings.TrimSpace(req.Provider.Sort)
if s == constant.RoutingPriorityAuto || s == constant.RoutingPriorityPrice ||
s == constant.RoutingPrioritySpeed || s == constant.RoutingPrioritySuccessRate {
return s
}
return ""
}
+128
View File
@@ -0,0 +1,128 @@
package service
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCacheGetRandomSatisfiedChannelUsesGroupOrder verifies the openLUX group_ids
// equivalent (Token.GroupOrder): routing follows the ordered group list even when
// the token's group is NOT "auto", falling through to the next group when the
// current one has no channel.
func TestCacheGetRandomSatisfiedChannelUsesGroupOrder(t *testing.T) {
oldRetryTimes := common.RetryTimes
common.RetryTimes = 0
defer func() {
common.RetryTimes = oldRetryTimes
}()
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "group-order-runtime-model"
createChannelSelectAutoGroupsChannel(t, db, 2201, "vip", modelName)
createChannelSelectAutoGroupsChannel(t, db, 2202, "default", modelName)
model.InitChannelCache()
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
// group_order:有序分组列表,令牌 group 保持普通分组(此处 "vip",非 "auto"
common.SetContextKey(ctx, constant.ContextKeyTokenGroupOrder, []string{"vip", "default"})
common.SetContextKey(ctx, constant.ContextKeyTokenCrossGroupRetry, true)
retry := 0
param := &RetryParam{
Ctx: ctx,
TokenGroup: "vip",
ModelName: modelName,
RequestPath: "/v1/chat/completions",
Retry: &retry,
}
first, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, first)
assert.Equal(t, 2201, first.Id)
assert.Equal(t, "vip", selectedGroup)
assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
param.IncreaseRetry()
second, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, second)
assert.Equal(t, 2202, second.Id)
assert.Equal(t, "default", selectedGroup)
assert.Equal(t, "default", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
}
// TestCacheGetRandomSatisfiedChannelForcedGroupByModelSuffix verifies the request-level
// model suffix override (model@g2, openLUX compatible): the forced group wins over
// the token's single group.
func TestCacheGetRandomSatisfiedChannelForcedGroupByModelSuffix(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "suffix-override-runtime-model"
createChannelSelectAutoGroupsChannel(t, db, 2203, "vip", modelName)
createChannelSelectAutoGroupsChannel(t, db, 2204, "default", modelName)
model.InitChannelCache()
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
// 请求 model 形如 "deepseek-chat@vip"distributor 解析后写入强制分组
common.SetContextKey(ctx, constant.ContextKeyModelGroupOverride, "vip")
retry := 0
param := &RetryParam{
Ctx: ctx,
TokenGroup: "default",
ModelName: modelName,
RequestPath: "/v1/chat/completions",
Retry: &retry,
}
channel, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, channel)
assert.Equal(t, 2203, channel.Id)
assert.Equal(t, "vip", selectedGroup)
assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
}
// TestApplyModelGroupSuffixGroupOrderPrecedence verifies that when the model suffix
// override is present it beats the token-level group_order.
func TestApplyModelGroupSuffixBeatsGroupOrder(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "suffix-beats-order-runtime-model"
createChannelSelectAutoGroupsChannel(t, db, 2205, "vip", modelName)
createChannelSelectAutoGroupsChannel(t, db, 2206, "default", modelName)
model.InitChannelCache()
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
// 同时有 group_order 与 model 后缀强制分组:强制分组优先级更高
common.SetContextKey(ctx, constant.ContextKeyTokenGroupOrder, []string{"default", "vip"})
common.SetContextKey(ctx, constant.ContextKeyModelGroupOverride, "vip")
retry := 0
param := &RetryParam{
Ctx: ctx,
TokenGroup: "default",
ModelName: modelName,
RequestPath: "/v1/chat/completions",
Retry: &retry,
}
channel, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, channel)
assert.Equal(t, 2205, channel.Id)
assert.Equal(t, "vip", selectedGroup)
assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
}
+94
View File
@@ -1,6 +1,8 @@
package service
import (
"math"
"sort"
"strings"
"github.com/QuantumNous/new-api/common"
@@ -131,3 +133,95 @@ func GetUserGroupRatio(userGroup, group string) float64 {
}
return ratio_setting.GetGroupRatio(group)
}
// GetSmartRoutingGroups 计算智能路由(Token.RoutingPriority)下应使用的有序分组列表。
// 仅包含用户可用分组(含特殊可用分组)中该模型存在可用渠道的分组。
//
// 排序策略:
// - price:按用户实际使用倍率升序(成本低优先),同倍率按响应速度升序;
// - speed:按候选渠道最短响应时间升序(无记录排后),同速度按优先级降序;
// - success_rate:按候选渠道实测成功率降序(无请求记录的分组排最后),同成功率按优先级、再按速度升序;
// - auto:综合排序,先成功率(优先级)降序,再价格升序,最后速度升序。
//
// 注:Channel 模型维护请求/成功计数(Distribute 记录,dirty-map+flusher 落库),
// success_rate 用实测成功率排序;auto 仍用「优先级 + 响应时间」作为稳定性代理。
func GetSmartRoutingGroups(userGroup, modelName, requestPath, routingPriority string) []string {
usable := GetUserUsableGroups(userGroup)
groups := make([]string, 0, len(usable))
for group := range usable {
if group == "" || group == "auto" {
continue
}
groups = append(groups, group)
}
stats := model.GetGroupChannelStats(groups, modelName, requestPath)
type rankedGroup struct {
group string
ratio float64
stat *model.GroupChannelStat
}
ranked := make([]rankedGroup, 0, len(groups))
for _, group := range groups {
st, ok := stats[group]
if !ok || !st.HasChannel {
continue
}
ranked = append(ranked, rankedGroup{group: group, ratio: GetUserGroupRatio(userGroup, group), stat: st})
}
// rt 将「无响应记录(0)」归一化为最大整数,使其总是排到最后。
rt := func(ms int) int {
if ms == 0 {
return math.MaxInt32
}
return ms
}
sort.SliceStable(ranked, func(i, j int) bool {
a, b := ranked[i], ranked[j]
switch routingPriority {
case constant.RoutingPriorityPrice:
if a.ratio != b.ratio {
return a.ratio < b.ratio
}
return rt(a.stat.MinResponseTime) < rt(b.stat.MinResponseTime)
case constant.RoutingPrioritySpeed:
if rt(a.stat.MinResponseTime) != rt(b.stat.MinResponseTime) {
return rt(a.stat.MinResponseTime) < rt(b.stat.MinResponseTime)
}
return a.stat.MaxPriority > b.stat.MaxPriority
case constant.RoutingPrioritySuccessRate:
// 成功率优先:无请求记录的分组排最后,其余按实测成功率降序。
as, bs := a.stat.SuccessRate, b.stat.SuccessRate
if a.stat.TotalRequest == 0 {
as = -1
}
if b.stat.TotalRequest == 0 {
bs = -1
}
if as != bs {
return as > bs
}
if a.stat.MaxPriority != b.stat.MaxPriority {
return a.stat.MaxPriority > b.stat.MaxPriority
}
return rt(a.stat.MinResponseTime) < rt(b.stat.MinResponseTime)
default: // auto:综合
if a.stat.MaxPriority != b.stat.MaxPriority {
return a.stat.MaxPriority > b.stat.MaxPriority
}
if a.ratio != b.ratio {
return a.ratio < b.ratio
}
return rt(a.stat.MinResponseTime) < rt(b.stat.MinResponseTime)
}
})
result := make([]string, 0, len(ranked))
for _, r := range ranked {
result = append(result, r.group)
}
return result
}
+14
View File
@@ -523,6 +523,20 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
attachQuotaSaturation(ctx, relayInfo, other)
// 记录渠道真实响应时间(智能路由 speed/success_rate 策略的数据源)。
// 成功结算路径在此统一上报:有首包时间用首包延迟,否则用从开始到现在的耗时。
if relayInfo != nil && relayInfo.ChannelId > 0 {
latencyMs := int64(0)
if relayInfo.HasSendResponse() {
latencyMs = relayInfo.FirstResponseTime.Sub(relayInfo.StartTime).Milliseconds()
} else if !relayInfo.StartTime.IsZero() {
latencyMs = time.Since(relayInfo.StartTime).Milliseconds()
}
if latencyMs > 0 {
model.CacheUpdateChannelResponseTime(relayInfo.ChannelId, int(latencyMs))
}
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: summary.PromptTokens,
+6 -3
View File
@@ -32,13 +32,16 @@ type GroupBadgeProps = Omit<
}
function getGroupRatioClassName(ratio: number): string {
if (ratio > 1) {
if (ratio > 7) {
return 'bg-destructive/10 text-destructive'
}
if (ratio > 3) {
return 'bg-warning/10 text-warning'
}
if (ratio < 1) {
if (ratio > 1) {
return 'bg-info/10 text-info'
}
return 'bg-muted text-muted-foreground'
return 'bg-success/10 text-success'
}
function getGroupLabel(params: {
@@ -37,11 +37,20 @@ type ApiKeyGroupCellProps = {
crossGroupRetry: boolean
group: string
ratio?: GroupRatio
routingPriority?: string
shouldReduceMotion: boolean
}
const SMART_ROUTING_LABELS: Record<string, string> = {
auto: 'Auto',
price: 'Price',
speed: 'Speed',
success_rate: 'Success rate',
}
export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
const { t } = useTranslation()
const isSmart = !!props.routingPriority
if (props.group !== 'auto') {
const ratio = typeof props.ratio === 'number' ? props.ratio : undefined
@@ -56,21 +65,33 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
)
}
const strategyLabel = isSmart
? t(SMART_ROUTING_LABELS[props.routingPriority!] ?? 'Auto')
: null
return (
<Tooltip>
<TooltipTrigger
render={
<BadgeCell
data-api-key-group-cell='auto'
data-api-key-group-cell={isSmart ? 'smart' : 'auto'}
className='gap-1.5 overflow-visible text-xs'
/>
}
>
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
{isSmart ? (
<StatusBadge
label={t('Smart: {{strategy}}', { strategy: strategyLabel })}
variant='success'
copyable={false}
/>
) : (
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
)}
{/*<AutoGroupBadge shouldReduceMotion={props.shouldReduceMotion} />*/}
<GroupRatioBadge
ratio={props.ratio}
@@ -80,9 +101,13 @@ export function ApiKeyGroupCell(props: ApiKeyGroupCellProps) {
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
{t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
{isSmart
? t(
'Smart routing: the system picks the optimal channel across all usable groups by the selected strategy.'
)
: t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
</span>
</TooltipContent>
</Tooltip>
@@ -201,6 +201,7 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
group={group}
ratio={groupRatios[group]}
crossGroupRetry={apiKey.cross_group_retry}
routingPriority={apiKey.routing_priority ?? undefined}
shouldReduceMotion={shouldReduceMotion}
/>
)
@@ -60,6 +60,13 @@ import {
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useStatus } from '@/hooks/use-status'
@@ -258,6 +265,8 @@ export function ApiKeysMutateDrawer({
isUpdate && currentRow ? `update:${currentRow.id}` : 'create'
const isFormInitialized = initializedTarget === formTarget
const selectedGroup = form.watch('group')
// 二选一:smartRouting=true 时按策略智能路由,否则走指定分组(group/auto_groups
const smartRouting = form.watch('routing_priority') || ''
// Correct group after groups load: if the form value is not in available groups, fall back
useEffect(() => {
@@ -412,12 +421,76 @@ export function ApiKeysMutateDrawer({
)}
/>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<div className={sideDrawerSwitchItemClassName()}>
<div className='flex flex-col gap-0.5'>
<span className='text-sm font-medium'>{t('Smart routing')}</span>
<span className='line-clamp-2 text-muted-foreground text-xs sm:line-clamp-none'>
{t(
'When enabled, the system picks the optimal channel across all usable groups by strategy, ignoring the specified group.'
)}
</span>
</div>
<Switch
aria-label={t('Smart routing')}
checked={!!smartRouting}
onCheckedChange={(on) => {
form.setValue('routing_priority', on ? 'auto' : '', {
shouldDirty: true,
})
form.setValue('cross_group_retry', on, {
shouldDirty: true,
})
}}
/>
</div>
{!!smartRouting && (
<FormField
control={form.control}
name='routing_priority'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Routing strategy')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger className='w-full'>
<SelectValue
placeholder={t('Select a routing strategy')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value='auto'>
{t('Auto (price, speed, success rate)')}
</SelectItem>
<SelectItem value='price'>
{t('Price first (lowest cost)')}
</SelectItem>
<SelectItem value='speed'>
{t('Speed first (fastest response)')}
</SelectItem>
<SelectItem value='success_rate'>
{t('Success rate first (most stable)')}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{!smartRouting && (
<>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<FormControl>
<ApiKeyGroupCombobox
options={groups}
@@ -510,6 +583,42 @@ export function ApiKeysMutateDrawer({
/>
)}
{!smartRouting && selectedGroup !== 'auto' && (
<FormField
control={form.control}
name='group_order'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Group priority order (openLUX group_ids)')}
</FormLabel>
<FormDescription>
{t(
'Optional. Requests fall back through these groups in order. When set, it overrides the single Group above.'
)}
</FormDescription>
<FormControl>
<AutoGroupOrderEditor
value={field.value}
mode='custom'
options={groups}
globalOptions={[]}
maxCount={maxAutoGroups}
onChange={(value) =>
field.onChange(
value.groups.slice(0, maxAutoGroups)
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
<FormField
control={form.control}
name='expired_time'
@@ -72,7 +72,7 @@ function getRatioBadgeClassName(ratio: GroupRatio, isAuto: boolean): string {
if (isAuto || typeof ratio !== 'number') {
return 'border-primary/30 bg-primary/10 text-primary'
}
if (ratio > 5) {
if (ratio > 7) {
return 'border-destructive/30 bg-destructive/10 text-destructive'
}
if (ratio > 3) {
@@ -48,6 +48,9 @@ const baseApiKey: ApiKey = {
group: 'auto',
auto_groups: null,
cross_group_retry: true,
routing_priority: null,
manual_group: null,
group_order: null,
model_limits_enabled: false,
model_limits: '',
allow_ips: '',
+37 -6
View File
@@ -43,11 +43,15 @@ export function getApiKeyFormSchema(t: TFunction, maxAutoGroups = 5) {
group: z.string().optional(),
auto_groups_mode: z.enum(['inherit', 'custom']),
auto_groups: z.array(z.string()),
// 有序分组列表(openLUX group_ids 对齐):非智能路由且非 auto 分组时生效
group_order: z.array(z.string()),
cross_group_retry: z.boolean().optional(),
routing_priority: z.string().optional(),
tokenCount: z.number().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.group === 'auto') {
// 智能路由模式下忽略指定分组,不做 auto_groups 校验;quota 校验对所有模式生效
if (!data.routing_priority && data.group === 'auto') {
if (
data.auto_groups_mode === 'custom' &&
data.auto_groups.length === 0
@@ -113,7 +117,9 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = {
group: DEFAULT_GROUP,
auto_groups_mode: 'inherit',
auto_groups: [],
group_order: [],
cross_group_retry: true,
routing_priority: '',
tokenCount: 1,
}
@@ -126,6 +132,7 @@ export function getApiKeyFormDefaultValues(
auto_groups_mode: 'inherit',
auto_groups: [],
cross_group_retry: defaultUseAutoGroup,
routing_priority: '',
}
}
@@ -139,6 +146,8 @@ export function getApiKeyFormDefaultValues(
export function transformFormDataToPayload(
data: ApiKeyFormValues
): ApiKeyFormData {
// 二选一:智能路由忽略指定分组,强制 group=auto + 跨分组重试
const isSmartRouting = !!data.routing_priority
return {
name: data.name,
remain_quota: data.unlimited_quota
@@ -151,12 +160,25 @@ 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 || '',
auto_groups:
data.group === 'auto' && data.auto_groups_mode === 'custom'
group: isSmartRouting ? 'auto' : data.group || '',
auto_groups: isSmartRouting
? []
: data.group === 'auto' && data.auto_groups_mode === 'custom'
? data.auto_groups
: [],
cross_group_retry: data.group === 'auto' ? !!data.cross_group_retry : false,
// 有序分组列表(openLUX group_ids):非智能路由且非 auto 分组时生效
group_order:
isSmartRouting || data.group === 'auto'
? ''
: (data.group_order || []).join(','),
cross_group_retry: isSmartRouting
? true
: data.group === 'auto'
? !!data.cross_group_retry
: false,
routing_priority: isSmartRouting ? data.routing_priority || '' : '',
// 开启智能路由时把原分组带给后端存为 manual_group,切回手动时恢复
manual_group: isSmartRouting ? data.group || '' : '',
}
}
@@ -189,10 +211,19 @@ export function transformApiKeyToFormDefaults(
? apiKey.model_limits.split(',').filter(Boolean)
: [],
allow_ips: apiKey.allow_ips || '',
group: apiKey.group || DEFAULT_GROUP,
// 智能路由令牌的 group 在库里是 "auto",用 manual_group 恢复原分组显示,
// 这样关闭开关时表单提交的 group 就是原分组,不会丢失。
group:
apiKey.group === 'auto' && apiKey.manual_group
? apiKey.manual_group
: apiKey.group || DEFAULT_GROUP,
auto_groups_mode: autoGroupsMode,
auto_groups: autoGroups,
group_order: apiKey.group_order
? apiKey.group_order.split(',').filter(Boolean)
: [],
cross_group_retry: !!apiKey.cross_group_retry,
routing_priority: apiKey.routing_priority || '',
tokenCount: 1,
}
}
+10
View File
@@ -43,6 +43,13 @@ export const apiKeySchema = z.object({
}, z.boolean())
.optional()
.default(false),
// 二选一:'' = 指定分组(group / auto_groups),
// auto/price/speed/success_rate = 智能路由
routing_priority: z.string().nullish().default(''),
// 开启智能路由前的手动分组,切回手动时恢复 group 使用
manual_group: z.string().nullish().default(''),
// 有序分组列表(openLUX group_ids 对齐),逗号分隔,按优先级排列
group_order: z.string().nullish().default(''),
model_limits_enabled: z.boolean(),
model_limits: z.string().nullish().default(''),
allow_ips: z.string().nullish().default(''),
@@ -94,6 +101,9 @@ export interface ApiKeyFormData {
group: string
auto_groups: string[]
cross_group_retry: boolean
routing_priority: string
manual_group: string
group_order: string
}
export interface TokenAutoGroupsConfig {
+12 -1
View File
@@ -5537,6 +5537,17 @@
"Zero retention": "Zero retention",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
"Zoom": "Zoom",
"Speed": "Speed",
"Smart routing": "Smart routing",
"Routing strategy": "Routing strategy",
"Select a routing strategy": "Select a routing strategy",
"Auto (price, speed, success rate)": "Auto (price, speed, success rate)",
"Price first (lowest cost)": "Price first (lowest cost)",
"Speed first (fastest response)": "Speed first (fastest response)",
"Success rate first (most stable)": "Success rate first (most stable)",
"When enabled, the system picks the optimal channel across all usable groups by strategy, ignoring the specified group.": "When enabled, the system picks the optimal channel across all usable groups by strategy, ignoring the specified group.",
"Smart: {{strategy}}": "Smart: {{strategy}}",
"Smart routing: the system picks the optimal channel across all usable groups by the selected strategy.": "Smart routing: the system picks the optimal channel across all usable groups by the selected strategy."
}
}
+12 -1
View File
@@ -5537,6 +5537,17 @@
"Zero retention": "零数据保留",
"Zhipu": "智谱",
"Zhipu V4": "智谱 V4",
"Zoom": "缩放"
"Zoom": "缩放",
"Speed": "速度",
"Smart routing": "智能路由",
"Routing strategy": "路由策略",
"Select a routing strategy": "请选择路由策略",
"Auto (price, speed, success rate)": "综合最优(价格 / 速度 / 成功率)",
"Price first (lowest cost)": "价格优先(成本最低)",
"Speed first (fastest response)": "速度优先(响应最快)",
"Success rate first (most stable)": "成功率优先(近期最稳定)",
"When enabled, the system picks the optimal channel across all usable groups by strategy, ignoring the specified group.": "开启后,系统将按所选策略在所有可用分组中选择最优渠道,忽略指定分组。",
"Smart: {{strategy}}": "智能:{{strategy}}",
"Smart routing: the system picks the optimal channel across all usable groups by the selected strategy.": "智能路由:系统按所选策略在所有可用分组中选择最优渠道。"
}
}