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:
zizi 2026-04-16 21:05:22 +08:00
parent 67c173cbcb
commit e8642f4bba
4 changed files with 253 additions and 6 deletions

View File

@ -23,8 +23,15 @@ import (
)
type ModelRequest struct {
Model string `json:"model"`
Group string `json:"group,omitempty"`
Model string `json:"model"`
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) {
@ -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()}))
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 {
id, err := strconv.Atoi(channelId.(string))
if err != nil {
@ -98,6 +111,7 @@ func Distribute() func(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
}
}
usingGroup = service.ApplyMuseRoutingPolicy(c, usingGroup)
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
@ -109,7 +123,7 @@ func Distribute() func(c *gin.Context) {
}
} else if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
autoGroups := service.ResolveMusePreferredGroups(c, userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
@ -128,12 +142,31 @@ func Distribute() func(c *gin.Context) {
}
if channel == nil {
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
retryParam := &service.RetryParam{
Ctx: c,
ModelName: modelRequest.Model,
TokenGroup: usingGroup,
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 {
showGroup := usingGroup
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 从请求中读取模型信息
// 根据 Content-Type 自动处理:
// - application/json

View File

@ -90,7 +90,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
if len(setting.GetAutoGroups()) == 0 {
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: 开始搜索的分组索引

View 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))
}

View 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)
}