mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-08-31 02:41:34 +00:00
Merge c8da7a4baf into 2b6f1dfefb
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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之间"
|
||||
|
||||
@@ -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之間"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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.": "智能路由:系统按所选策略在所有可用分组中选择最优渠道。"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user