Let Muse scene policy drive relay retries and model fallback
Muse now sends scene, routing policy, and degrade policy with each per-user request, so new-api should interpret those hints instead of leaving retry scope and fallback behavior implicit in static token settings alone. This introduces a small Muse routing-policy interpreter, lets distributor switch quality-first traffic onto auto-group retry, and retries against lower-tier model candidates when the degrade policy allows it. The policy stays lightweight and still respects user-usable groups. Constraint: Routing decisions must remain compatible with existing channel/group selection and not require schema changes Constraint: Muse can hint policy, but new-api must still enforce only user-usable groups and channel availability Rejected: Hardcoding physical channel/group choices inside Muse | would keep routing ownership outside new-api Rejected: Expanding the policy into a full declarative DSL in phase 1 | overdesigned for the immediate routing/degrade handoff Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep Muse routing policy interpretation small and observable; if more scenes appear, extend the helper rather than scattering policy branches across middleware Tested: go test ./service -run MuseRoutingPolicy -count=1 Tested: go test ./service -count=1 Not-tested: Live relay selection against production channel inventory
This commit is contained in:
parent
67c173cbcb
commit
e8642f4bba
@ -23,8 +23,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ModelRequest struct {
|
type ModelRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Group string `json:"group,omitempty"`
|
Group string `json:"group,omitempty"`
|
||||||
|
Metadata *MuseRoutingPolicyInput `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MuseRoutingPolicyInput struct {
|
||||||
|
Scene string `json:"scene,omitempty"`
|
||||||
|
RoutingPolicy string `json:"routing_policy,omitempty"`
|
||||||
|
DegradePolicy string `json:"degrade_policy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Distribute() func(c *gin.Context) {
|
func Distribute() func(c *gin.Context) {
|
||||||
@ -36,6 +43,12 @@ func Distribute() func(c *gin.Context) {
|
|||||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
|
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
policy := service.ResolveMuseRoutingPolicy(
|
||||||
|
firstNonBlank(common.GetContextKeyString(c, constant.ContextKeyMuseScene), modelRequest.MetadataValue("scene")),
|
||||||
|
modelRequest.MetadataValue("routing_policy"),
|
||||||
|
modelRequest.MetadataValue("degrade_policy"),
|
||||||
|
)
|
||||||
|
service.SetMuseRoutingPolicy(c, policy)
|
||||||
if ok {
|
if ok {
|
||||||
id, err := strconv.Atoi(channelId.(string))
|
id, err := strconv.Atoi(channelId.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -98,6 +111,7 @@ func Distribute() func(c *gin.Context) {
|
|||||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
|
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
usingGroup = service.ApplyMuseRoutingPolicy(c, usingGroup)
|
||||||
|
|
||||||
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
|
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
|
||||||
preferred, err := model.CacheGetChannel(preferredChannelID)
|
preferred, err := model.CacheGetChannel(preferredChannelID)
|
||||||
@ -109,7 +123,7 @@ func Distribute() func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
} else if usingGroup == "auto" {
|
} else if usingGroup == "auto" {
|
||||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||||
autoGroups := service.GetUserAutoGroup(userGroup)
|
autoGroups := service.ResolveMusePreferredGroups(c, userGroup)
|
||||||
for _, g := range autoGroups {
|
for _, g := range autoGroups {
|
||||||
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
|
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
|
||||||
selectGroup = g
|
selectGroup = g
|
||||||
@ -128,12 +142,31 @@ func Distribute() func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
|
retryParam := &service.RetryParam{
|
||||||
Ctx: c,
|
Ctx: c,
|
||||||
ModelName: modelRequest.Model,
|
ModelName: modelRequest.Model,
|
||||||
TokenGroup: usingGroup,
|
TokenGroup: usingGroup,
|
||||||
Retry: common.GetPointer(0),
|
Retry: common.GetPointer(0),
|
||||||
})
|
}
|
||||||
|
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(retryParam)
|
||||||
|
if channel == nil && err == nil {
|
||||||
|
candidates := service.MuseFallbackModelCandidates(modelRequest.Model, policy)
|
||||||
|
for _, candidateModel := range candidates[1:] {
|
||||||
|
candidateParam := &service.RetryParam{
|
||||||
|
Ctx: c,
|
||||||
|
ModelName: candidateModel,
|
||||||
|
TokenGroup: usingGroup,
|
||||||
|
Retry: common.GetPointer(0),
|
||||||
|
}
|
||||||
|
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(candidateParam)
|
||||||
|
if channel != nil || err != nil {
|
||||||
|
if channel != nil {
|
||||||
|
modelRequest.Model = candidateModel
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
showGroup := usingGroup
|
showGroup := usingGroup
|
||||||
if usingGroup == "auto" {
|
if usingGroup == "auto" {
|
||||||
@ -164,6 +197,31 @@ func Distribute() func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ModelRequest) MetadataValue(name string) string {
|
||||||
|
if r == nil || r.Metadata == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "scene":
|
||||||
|
return r.Metadata.Scene
|
||||||
|
case "routing_policy":
|
||||||
|
return r.Metadata.RoutingPolicy
|
||||||
|
case "degrade_policy":
|
||||||
|
return r.Metadata.DegradePolicy
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonBlank(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// getModelFromRequest 从请求中读取模型信息
|
// getModelFromRequest 从请求中读取模型信息
|
||||||
// 根据 Content-Type 自动处理:
|
// 根据 Content-Type 自动处理:
|
||||||
// - application/json
|
// - application/json
|
||||||
|
|||||||
@ -90,7 +90,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
|
|||||||
if len(setting.GetAutoGroups()) == 0 {
|
if len(setting.GetAutoGroups()) == 0 {
|
||||||
return nil, selectGroup, errors.New("auto groups is not enabled")
|
return nil, selectGroup, errors.New("auto groups is not enabled")
|
||||||
}
|
}
|
||||||
autoGroups := GetUserAutoGroup(userGroup)
|
autoGroups := ResolveMusePreferredGroups(param.Ctx, userGroup)
|
||||||
|
|
||||||
// startGroupIndex: the group index to start searching from
|
// startGroupIndex: the group index to start searching from
|
||||||
// startGroupIndex: 开始搜索的分组索引
|
// startGroupIndex: 开始搜索的分组索引
|
||||||
|
|||||||
146
service/muse_routing_policy.go
Normal file
146
service/muse_routing_policy.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ginKeyMuseRoutingPolicy = "muse_routing_policy"
|
||||||
|
|
||||||
|
type MuseRoutingPolicy struct {
|
||||||
|
Scene string
|
||||||
|
AllowCrossGroupRetry bool
|
||||||
|
AllowModelDegrade bool
|
||||||
|
PreferredGroups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveMuseRoutingPolicy(scene string, routingPolicy string, degradePolicy string) MuseRoutingPolicy {
|
||||||
|
scene = normalizeMusePolicyValue(scene)
|
||||||
|
routingPolicy = normalizeMusePolicyValue(routingPolicy)
|
||||||
|
degradePolicy = normalizeMusePolicyValue(degradePolicy)
|
||||||
|
|
||||||
|
policy := MuseRoutingPolicy{
|
||||||
|
Scene: scene,
|
||||||
|
PreferredGroups: defaultMusePreferredGroups(scene, routingPolicy),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch routingPolicy {
|
||||||
|
case "quality_first", "balanced":
|
||||||
|
policy.AllowCrossGroupRetry = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if degradePolicy == "allow_lower_tier" {
|
||||||
|
policy.AllowModelDegrade = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetMuseRoutingPolicy(c *gin.Context, policy MuseRoutingPolicy) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if policy.Scene == "" && !policy.AllowCrossGroupRetry && !policy.AllowModelDegrade && len(policy.PreferredGroups) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(ginKeyMuseRoutingPolicy, policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMuseRoutingPolicy(c *gin.Context) (MuseRoutingPolicy, bool) {
|
||||||
|
if c == nil {
|
||||||
|
return MuseRoutingPolicy{}, false
|
||||||
|
}
|
||||||
|
value, ok := c.Get(ginKeyMuseRoutingPolicy)
|
||||||
|
if !ok {
|
||||||
|
return MuseRoutingPolicy{}, false
|
||||||
|
}
|
||||||
|
policy, ok := value.(MuseRoutingPolicy)
|
||||||
|
if !ok {
|
||||||
|
return MuseRoutingPolicy{}, false
|
||||||
|
}
|
||||||
|
return policy, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyMuseRoutingPolicy(c *gin.Context, usingGroup string) string {
|
||||||
|
policy, ok := GetMuseRoutingPolicy(c)
|
||||||
|
if !ok {
|
||||||
|
return usingGroup
|
||||||
|
}
|
||||||
|
if policy.AllowCrossGroupRetry {
|
||||||
|
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, true)
|
||||||
|
if usingGroup != "auto" {
|
||||||
|
usingGroup = "auto"
|
||||||
|
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usingGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveMusePreferredGroups(c *gin.Context, userGroup string) []string {
|
||||||
|
policy, ok := GetMuseRoutingPolicy(c)
|
||||||
|
if !ok || len(policy.PreferredGroups) == 0 {
|
||||||
|
return GetUserAutoGroup(userGroup)
|
||||||
|
}
|
||||||
|
usableGroups := GetUserUsableGroups(userGroup)
|
||||||
|
filtered := make([]string, 0, len(policy.PreferredGroups))
|
||||||
|
for _, group := range policy.PreferredGroups {
|
||||||
|
if _, allowed := usableGroups[group]; allowed {
|
||||||
|
filtered = append(filtered, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return GetUserAutoGroup(userGroup)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func MuseFallbackModelCandidates(modelName string, policy MuseRoutingPolicy) []string {
|
||||||
|
candidates := []string{modelName}
|
||||||
|
if !policy.AllowModelDegrade {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
appendUnique := func(candidate string) {
|
||||||
|
candidate = strings.TrimSpace(candidate)
|
||||||
|
if candidate == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !slices.Contains(candidates, candidate) {
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(modelName, "gpt-4o") && modelName != "gpt-4o-mini":
|
||||||
|
appendUnique("gpt-4o-mini")
|
||||||
|
case strings.Contains(modelName, "gemini") && strings.Contains(modelName, "pro"):
|
||||||
|
appendUnique(strings.Replace(modelName, "pro", "flash", 1))
|
||||||
|
case strings.Contains(modelName, "claude") && strings.Contains(modelName, "opus"):
|
||||||
|
appendUnique(strings.Replace(modelName, "opus", "sonnet", 1))
|
||||||
|
case strings.Contains(modelName, "claude") && strings.Contains(modelName, "sonnet"):
|
||||||
|
appendUnique(strings.Replace(modelName, "sonnet", "haiku", 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultMusePreferredGroups(scene string, routingPolicy string) []string {
|
||||||
|
switch routingPolicy {
|
||||||
|
case "quality_first":
|
||||||
|
return []string{"svip", "vip", "default"}
|
||||||
|
case "balanced":
|
||||||
|
return []string{"vip", "default"}
|
||||||
|
}
|
||||||
|
if scene == "suggestion_generation" {
|
||||||
|
return []string{"default"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMusePolicyValue(value string) string {
|
||||||
|
return strings.TrimSpace(strings.ToLower(value))
|
||||||
|
}
|
||||||
43
service/muse_routing_policy_test.go
Normal file
43
service/muse_routing_policy_test.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveMuseRoutingPolicy_QualityFirstAllowsFallback(t *testing.T) {
|
||||||
|
policy := ResolveMuseRoutingPolicy("suggestion_generation", "quality_first", "allow_lower_tier")
|
||||||
|
|
||||||
|
require.Equal(t, "suggestion_generation", policy.Scene)
|
||||||
|
require.True(t, policy.AllowCrossGroupRetry)
|
||||||
|
require.True(t, policy.AllowModelDegrade)
|
||||||
|
require.Equal(t, []string{"svip", "vip", "default"}, policy.PreferredGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMuseRoutingPolicy_DefaultsWithoutPolicies(t *testing.T) {
|
||||||
|
policy := ResolveMuseRoutingPolicy("suggestion_generation", "", "")
|
||||||
|
|
||||||
|
require.False(t, policy.AllowCrossGroupRetry)
|
||||||
|
require.False(t, policy.AllowModelDegrade)
|
||||||
|
require.Equal(t, []string{"default"}, policy.PreferredGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMuseFallbackModelCandidates_AllowLowerTierAddsFallback(t *testing.T) {
|
||||||
|
policy := ResolveMuseRoutingPolicy("suggestion_generation", "quality_first", "allow_lower_tier")
|
||||||
|
|
||||||
|
candidates := MuseFallbackModelCandidates("gpt-4o", policy)
|
||||||
|
|
||||||
|
require.Equal(t, []string{"gpt-4o", "gpt-4o-mini"}, candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMusePreferredGroups_UsesSceneDrivenDefaultsFromContext(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ctx, _ := gin.CreateTestContext(nil)
|
||||||
|
SetMuseRoutingPolicy(ctx, ResolveMuseRoutingPolicy("suggestion_generation", "", ""))
|
||||||
|
|
||||||
|
groups := ResolveMusePreferredGroups(ctx, "default")
|
||||||
|
|
||||||
|
require.Equal(t, []string{"default"}, groups)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user