feat: sync deployed v1.0.0-rc.38-custom.67

This commit is contained in:
cneicy committed 2026-10-06 00:07:10 +08:00
1 parent d09216bbde
commit 8e6ed0317b
154 files changed
+39540 -20509

No files matched your search

+17 -3
View File
@@ -11,7 +11,7 @@ import (
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
)
const AstraSamplingTaskType = "astra_fingerprint"
const AstraSamplingTaskType = "modeltrace_sampling"
const (
AstraSamplingWindow = 6 * time.Hour
@@ -55,6 +55,7 @@ type AstraSamplingHistory struct {
Gap float64 `json:"score_gap,omitempty"`
}
type AstraSamplingSummary struct {
LatestValid *AstraSamplingHistory `json:"-"`
Enabled bool `json:"enabled"`
Available bool `json:"available"`
Channels int `json:"channels"`
@@ -123,6 +124,9 @@ func AstraAvailabilityIntervalMinutes() int {
// Public statistics classify completed rounds by their closest valid fingerprint,
// including partial rounds. Raw probe results retain the scorer's confidence.
func AstraSamplingResultStatus(result fingerprint.Result) string {
if result.Engine != "" {
return result.Status
}
closest := strings.TrimSpace(result.Closest)
if result.Status != "running" && result.ValidSamples > 0 && closest != "" {
if strings.EqualFold(closest, fingerprint.Model) {
@@ -162,7 +166,7 @@ func samplingChannels(ctx context.Context, requireAstra bool) ([]model.Channel,
}
func AstraSamplingChannelsDue(ctx context.Context, now time.Time) ([]model.Channel, error) {
due, _, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", now)
due, _, err := AutomaticDetectionChannelsDue(ctx, "modeltrace", now)
return due, err
}
@@ -183,7 +187,7 @@ func AstraSamplingSummaries(ctx context.Context, now time.Time, settings map[str
}
summaries := aggregateAstraSampling(channels, tasks, now, settings)
AggregateChannelAvailability(summaries, channels, availabilityTasks, now)
ranges, err := DetectionScheduleRanges(ctx, channels, "fingerprint")
ranges, err := DetectionScheduleRanges(ctx, channels, "modeltrace")
if err != nil {
return nil, err
}
@@ -224,6 +228,9 @@ func aggregateAstraSampling(channels []model.Channel, tasks []*model.SystemTask,
continue
}
for _, round := range state.Rounds {
if round.Result.Engine != fingerprint.Version && round.Result.Status != "running" {
continue
}
if round.StartedAt < now.Add(-AstraSamplingWindow).Unix() {
continue
}
@@ -272,6 +279,13 @@ func aggregateAstraSampling(channels []model.Channel, tasks []*model.SystemTask,
s.Rate = &rate
}
sort.SliceStable(s.History, func(i, j int) bool { return s.History[i].At > s.History[j].At })
for i := range s.History {
round := &s.History[i]
if round.ValidSamples > 0 && round.Closest != "" && (round.Status == "match" || round.Status == "mismatch") {
s.LatestValid = round
break
}
}
if len(s.History) > 12 {
s.History = s.History[:12]
}
+23 -24
View File
@@ -21,7 +21,7 @@ func TestAstraSamplingThirtyMinuteSchedulePersistsAcrossRestart(t *testing.T) {
handler := &anchoredSamplingTestHandler{stubScheduledHandler{taskType: AstraSamplingTaskType, enabled: true, interval: 30 * time.Minute}}
withSystemTaskRegistry(t, handler)
now := common.GetTimestamp()
state, _ := common.Marshal(AstraSamplingState{Rounds: []AstraSamplingRound{{ChannelID: 42, StartedAt: now - 1900, FinishedAt: now - 1800, Result: fingerprint.Result{Status: "match", ValidSamples: 3, Closest: fingerprint.Model}}}})
state, _ := common.Marshal(AstraSamplingState{Rounds: []AstraSamplingRound{{ChannelID: 42, StartedAt: now - 1900, FinishedAt: now - 1800, Result: fingerprint.Result{Engine: fingerprint.Version, Status: "match", ValidSamples: 3, Closest: fingerprint.Model}}}})
row := model.SystemTask{TaskID: "sampling-old", Type: AstraSamplingTaskType, Status: model.SystemTaskStatusSucceeded, CreatedAt: now - 1900, UpdatedAt: now - 10, State: string(state)}
if err := model.DB.Create(&row).Error; err != nil {
t.Fatal(err)
@@ -61,9 +61,9 @@ func TestAstraSamplingAggregationPrivacyAndFailures(t *testing.T) {
if status == "mismatch" {
closest = "gpt-5.6-luna"
}
rounds = append(rounds, AstraSamplingRound{ChannelID: 1, StartedAt: now.Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Status: status, ValidSamples: 3, Closest: closest}})
rounds = append(rounds, AstraSamplingRound{ChannelID: 1, StartedAt: now.Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: status, ValidSamples: 3, Closest: closest}})
}
rounds = append(rounds, AstraSamplingRound{ChannelID: 99, StartedAt: now.Unix(), Result: fingerprint.Result{Status: "mismatch"}})
rounds = append(rounds, AstraSamplingRound{ChannelID: 99, StartedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch"}})
state, _ := common.Marshal(AstraSamplingState{Rounds: rounds})
summaries := AggregateAstraSampling(channels, []*model.SystemTask{{Status: model.SystemTaskStatusFailed, State: string(state)}}, now)
first := summaries["first"]
@@ -88,17 +88,17 @@ func TestAstraSamplingAggregationPrivacyAndFailures(t *testing.T) {
}
}
func TestAstraSamplingAggregationCountsLunaLikeInconclusiveWithinSixHours(t *testing.T) {
func TestAstraSamplingExcludesInconclusiveFromRateWithinSixHours(t *testing.T) {
now := time.Now()
channel := model.Channel{Id: 1, Group: "example-variable", Models: fingerprint.Model}
rounds := []AstraSamplingRound{
{ID: "recent-match", ChannelID: 1, StartedAt: now.Add(-time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Status: "match", ValidSamples: 3, Closest: fingerprint.Model}},
{ID: "recent-luna", ChannelID: 1, StartedAt: now.Add(-2 * time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-luna", Gap: .9}},
{ID: "old-luna", ChannelID: 1, StartedAt: now.Add(-7 * time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-luna", Gap: .9}},
{ID: "recent-match", ChannelID: 1, StartedAt: now.Add(-time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "match", ValidSamples: 3, Closest: fingerprint.Model}},
{ID: "recent-luna", ChannelID: 1, StartedAt: now.Add(-2 * time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-luna", Gap: .9}},
{ID: "old-luna", ChannelID: 1, StartedAt: now.Add(-7 * time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-luna", Gap: .9}},
}
state, _ := common.Marshal(AstraSamplingState{Rounds: rounds})
summary := AggregateAstraSampling([]model.Channel{channel}, []*model.SystemTask{{Status: model.SystemTaskStatusSucceeded, State: string(state)}}, now)["example-variable"]
if summary.Rate == nil || *summary.Rate != .5 || summary.Matches != 1 || summary.Mismatches != 1 || summary.Inconclusive != 0 {
if summary.Rate == nil || *summary.Rate != 0 || summary.Matches != 1 || summary.Mismatches != 0 || summary.Inconclusive != 1 {
t.Fatalf("%+v", summary)
}
if summary.WindowHours != 6 || summary.IntervalMinutes != 30 || len(summary.History) != 2 {
@@ -107,6 +107,7 @@ func TestAstraSamplingAggregationCountsLunaLikeInconclusiveWithinSixHours(t *tes
}
func TestAstraSamplingAggregationMarksNonAstraGroupsAvailabilityOnly(t *testing.T) {
t.Setenv("CHANNEL_AVAILABILITY_INTERVAL_MINUTES", "30")
now := time.Now()
channels := []model.Channel{{Id: 1, Group: "opencode", Models: "gpt-5.6-sol"}}
summary := AggregateAstraSampling(channels, nil, now)["opencode"]
@@ -124,13 +125,12 @@ func TestAstraSamplingAggregationMarksNonAstraGroupsAvailabilityOnly(t *testing.
}
}
func TestAstraSamplingCountsAllValidNonAstraFingerprints(t *testing.T) {
func TestAstraSamplingExcludesOldEngineAndUncertainCandidates(t *testing.T) {
now := time.Now()
results := []fingerprint.Result{
{Status: "error", ValidSamples: 1, Closest: "gpt-5.6-luna", Gap: .99},
{Status: "inconclusive", ValidSamples: 3, Closest: "gpt-5.5", Gap: .17},
{Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-sol", Gap: .09},
{Status: "error", ValidSamples: 0},
{Engine: "meow-4.5.4", Status: "mismatch", ValidSamples: 32, Closest: "other"},
{Engine: fingerprint.Version, Status: "inconclusive", ValidSamples: 2, Closest: "other"},
{Engine: fingerprint.Version, Status: "match", ValidSamples: 3, Closest: fingerprint.Model},
}
var rounds []AstraSamplingRound
for _, result := range results {
@@ -140,16 +140,15 @@ func TestAstraSamplingCountsAllValidNonAstraFingerprints(t *testing.T) {
require.NoError(t, err)
summary := AggregateAstraSampling([]model.Channel{{Id: 1, Group: "test", Models: fingerprint.Model}}, []*model.SystemTask{{Status: model.SystemTaskStatusSucceeded, State: string(state)}}, now)["test"]
require.NotNil(t, summary.Rate)
assert.Equal(t, 1.0, *summary.Rate)
assert.Equal(t, 3, summary.Mismatches)
assert.Equal(t, 0, summary.Inconclusive)
assert.Equal(t, 1, summary.Errors)
for _, h := range summary.History {
if h.ValidSamples > 0 {
assert.Equal(t, "mismatch", h.Status)
}
assert.Equal(t, 0.0, *summary.Rate)
assert.Equal(t, 1, summary.Matches)
assert.Equal(t, 0, summary.Mismatches)
assert.Equal(t, 1, summary.Inconclusive)
assert.Len(t, summary.History, 2)
}
func TestModelTraceVerdictDoesNotPromoteClosestCandidate(t *testing.T) {
for _, status := range []string{"match", "mismatch", "inconclusive", "error"} {
assert.Equal(t, status, AstraSamplingResultStatus(fingerprint.Result{Engine: fingerprint.Version, Status: status, ValidSamples: 32, Closest: fingerprint.Model, Weight: .63436}))
}
assert.Equal(t, "match", AstraSamplingResultStatus(fingerprint.Result{Status: "inconclusive", ValidSamples: 1, Closest: fingerprint.Model}))
assert.Equal(t, "inconclusive", AstraSamplingResultStatus(fingerprint.Result{Status: "mismatch", ValidSamples: 0, Closest: "gpt-5.6-sol"}))
assert.Equal(t, "running", AstraSamplingResultStatus(fingerprint.Result{Status: "running", ValidSamples: 1, Closest: "gpt-5.6-sol"}))
}
+209
View File
@@ -0,0 +1,209 @@
package service
import (
"cmp"
"context"
"encoding/json"
"math"
"slices"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/gin-gonic/gin"
"github.com/samber/hot"
)
var automaticMetricCache = hot.NewHotCache[string, perfmetrics.QueryResult](hot.LRU, 1000).WithTTL(time.Minute).Build()
var automaticMetricMu sync.Mutex
func automaticModelMetrics(modelName string) perfmetrics.QueryResult {
automaticMetricMu.Lock()
defer automaticMetricMu.Unlock()
if value, found, err := automaticMetricCache.Get(modelName); err == nil && found {
return value
}
value, err := perfmetrics.Query(perfmetrics.QueryParams{Model: modelName, Hours: 24})
if err == nil {
automaticMetricCache.Set(modelName, value)
}
return value
}
type automaticCandidate struct {
group string
cost, ttft, success, ratio float64
}
func resolveAutomaticPool(c *gin.Context, modelName string) []model.TokenGroupEntry {
// Freeze ranking within a request: retries must not reorder their own pool.
key := "automatic_pool:" + modelName
if cached, ok := c.Get(key); ok {
return cached.([]model.TokenGroupEntry)
}
result := []model.TokenGroupEntry{}
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
var abilities []model.Ability
query := model.DB.WithContext(c.Request.Context()).Where("enabled = ?", true)
if modelName != "" {
query = query.Where("model = ?", modelName)
}
if err := query.Find(&abilities).Error; err != nil {
return result
}
allowed := GetUserUsableGroups(userGroup)
names := []string{}
seen := map[string]bool{}
for _, ability := range abilities {
group := ability.Group
if _, ok := allowed[group]; !ok || seen[group] || !IsUserSelectableGroup(userGroup, group) {
continue
}
channel, err := model.CacheGetChannel(ability.ChannelId)
if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled {
continue
}
seen[group] = true
names = append(names, group)
}
if len(names) == 0 {
c.Set(key, result)
return result
}
var performance perfmetrics.QueryResult
if modelName != "" {
performance = automaticModelMetrics(modelName)
}
testSpeeds := map[int]float64{}
if RequestRoutingPolicy(c).AutoPreference == "speed" {
testSpeeds = latestModelTestSpeeds(c.Request.Context(), modelName)
}
metrics := map[string]perfmetrics.GroupResult{}
for _, item := range performance.Groups {
metrics[item.Group] = item
}
candidates := []automaticCandidate{}
for _, group := range names {
if requestGroupDegraded(c, modelName, group) {
continue
}
ratio := model.EffectiveGroupRatio(c, userGroup, group).GroupRatio
item := automaticCandidate{group: group, ttft: math.Inf(1), success: -1, ratio: ratio}
if metric, ok := metrics[group]; ok && len(metric.Series) > 0 {
item.success = metric.SuccessRate
}
total, count := 0.0, 0
for _, ability := range abilities {
if ability.Group != group {
continue
}
channel, err := model.CacheGetChannel(ability.ChannelId)
if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled {
continue
}
if speed, ok := testSpeeds[ability.ChannelId]; ok {
total += speed
count++
}
}
if count > 0 {
item.ttft = total / float64(count)
}
candidates = append(candidates, item)
}
sortAutomaticCandidates(candidates, RequestRoutingPolicy(c).AutoPreference)
for priority, item := range candidates {
result = append(result, model.TokenGroupEntry{Group: item.group, Priority: priority})
}
c.Set(key, result)
return result
}
func sortAutomaticCandidates(items []automaticCandidate, preference string) {
slices.SortFunc(items, func(a, b automaticCandidate) int {
if preference == "speed" {
if n := cmp.Compare(a.ttft, b.ttft); n != 0 {
return n
}
}
if preference == "stable" {
if n := cmp.Compare(b.success, a.success); n != 0 {
return n
}
}
// Effective customer multipliers are independent of traffic sample size.
if n := cmp.Compare(a.ratio, b.ratio); n != 0 {
return n
}
if n := cmp.Compare(b.success, a.success); n != 0 {
return n
}
if n := cmp.Compare(a.ttft, b.ttft); n != 0 {
return n
}
if n := cmp.Compare(a.ratio, b.ratio); n != 0 {
return n
}
return cmp.Compare(a.group, b.group)
})
}
// AutomaticPoolModels is the discoverable model union, independent of ranking,
// temporary cooldowns and quality checks used when routing individual requests.
func AutomaticPoolModels(c *gin.Context) []string {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
var abilities []model.Ability
if err := model.DB.WithContext(c.Request.Context()).Where("enabled = ?", true).Find(&abilities).Error; err != nil {
common.SysError("automatic pool model discovery failed: " + err.Error())
return []string{}
}
names := map[string]bool{}
for _, ability := range abilities {
if !IsUserSelectableGroup(userGroup, ability.Group) {
continue
}
channel, err := model.CacheGetChannel(ability.ChannelId)
if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled {
continue
}
names[ability.Model] = true
}
result := make([]string, 0, len(names))
for name := range names {
result = append(result, name)
}
slices.Sort(result)
return result
}
// Model tests use token_id=0 and a server-owned content marker. User traffic and
// another model's timing must not masquerade as a speed measurement.
func latestModelTestSpeeds(ctx context.Context, modelName string) map[int]float64 {
result := map[int]float64{}
if model.LOG_DB == nil {
return result
}
var rows []model.Log
err := model.LOG_DB.WithContext(ctx).Select("id", "channel_id", "other").Where("type = ? AND token_id = ? AND content = ? AND model_name = ? AND is_stream = ? AND created_at >= ?", model.LogTypeConsume, 0, "模型测试", modelName, true, time.Now().Add(-time.Hour).Unix()).Order("created_at desc, id desc").Find(&rows).Error
if err != nil {
common.SysError("automatic routing test measurements unavailable: " + err.Error())
return result
}
for _, row := range rows {
if _, ok := result[row.ChannelId]; ok {
continue
}
var data struct {
FRT float64 `json:"frt"`
}
if json.Unmarshal([]byte(row.Other), &data) != nil || data.FRT <= 0 || math.IsInf(data.FRT, 0) || math.IsNaN(data.FRT) {
continue
}
result[row.ChannelId] = data.FRT
}
return result
}
+97
View File
@@ -0,0 +1,97 @@
package service
import (
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"net/http/httptest"
"os"
"testing"
"time"
)
func TestAutomaticPoolModelsDatabaseMatrix(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var driver gorm.Dialector
switch dialect {
case "sqlite":
driver = sqlite.Open(":memory:")
case "mysql":
if os.Getenv("TEST_MYSQL_DSN") == "" {
t.Skip("no isolated MySQL database")
}
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
case "postgres":
if os.Getenv("TEST_POSTGRES_DSN") == "" {
t.Skip("no isolated PostgreSQL database")
}
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("automodel_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
oldDB, oldRedis, oldMemory := model.DB, common.RedisEnabled, common.MemoryCacheEnabled
oldGroups, oldRatios := setting.UserUsableGroups2JSONString(), ratio_setting.GroupRatio2JSONString()
model.DB = db
common.RedisEnabled = false
common.MemoryCacheEnabled = false
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}))
t.Cleanup(func() {
require.NoError(t, db.Migrator().DropTable(&model.Ability{}, &model.Channel{}))
model.DB = oldDB
common.RedisEnabled = oldRedis
common.MemoryCacheEnabled = oldMemory
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(oldGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(oldRatios))
conn, e := db.DB()
require.NoError(t, e)
require.NoError(t, conn.Close())
})
var version string
query := "select version()"
if dialect == "sqlite" {
query = "select sqlite_version()"
}
require.NoError(t, db.Raw(query).Scan(&version).Error)
t.Log("database version:", version)
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"auto-a":"","auto-b":""}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"auto-a":1,"auto-b":1,"private":1}`))
require.NoError(t, db.Create(&[]model.Channel{{Id: 561, Status: 1}, {Id: 562, Status: 1}, {Id: 563, Status: 2}, {Id: 564, Status: 1}}).Error)
require.NoError(t, db.Create(&[]model.Ability{
{ChannelId: 561, Group: "auto-a", Model: "model-a", Enabled: true},
{ChannelId: 561, Group: "auto-a", Model: "shared", Enabled: true},
{ChannelId: 562, Group: "auto-b", Model: "shared", Enabled: true},
{ChannelId: 562, Group: "auto-b", Model: "gpt-6-astra", Enabled: true},
{ChannelId: 563, Group: "auto-b", Model: "disabled-channel", Enabled: true},
{ChannelId: 562, Group: "auto-b", Model: "disabled-ability", Enabled: false},
{ChannelId: 564, Group: "private", Model: "unauthorized", Enabled: true},
}).Error)
for _, preference := range []string{"cost", "speed", "stable"} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/v1/models", nil)
common.SetContextKey(c, constant.ContextKeyUserGroup, "auto-a")
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart", AutoPreference: preference})
c.Set("pool_failures", map[string]int{"auto-a": 2, "auto-b": 2})
require.Equal(t, []string{"gpt-6-astra", "model-a", "shared"}, AutomaticPoolModels(c))
}
require.NoError(t, db.Create(&model.Channel{Id: 565, Status: 1}).Error)
require.NoError(t, db.Create(&model.Ability{ChannelId: 565, Group: "new-group", Model: "new-model", Enabled: true}).Error)
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"auto-a":"","auto-b":"","new-group":""}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"auto-a":1,"auto-b":1,"new-group":1}`))
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/v1/models", nil)
common.SetContextKey(c, constant.ContextKeyUserGroup, "auto-a")
require.Equal(t, []string{"gpt-6-astra", "model-a", "new-model", "shared"}, AutomaticPoolModels(c))
})
}
}
+19
View File
@@ -0,0 +1,19 @@
package service
import "strings"
const CandyOutputTokens = 4096
const CandyPrompt = `在一个黑色的袋子里放有三种口味的糖果,每种糖果有两种不同的形状(圆形和五角星形,不同的形状靠手感可以分辨)。现已知不同口味的糖和不同形状的数量统计如下表。参赛者需要在活动前决定摸出的糖果数目,那么,最少取出多少个糖果才能保证手中同时拥有不同形状的苹果味和桃子味的糖?(同时手中有圆形苹果味匹配五角星桃子味糖果,或者有圆形桃子味匹配五角星苹果味糖果都满足要求)
苹果味 桃子味 西瓜味
圆形 7 9 8
五角星形 7 6 4
只输出最终答案的阿拉伯数字,不要输出解释或其他内容。`
func CandyQuality(answer string) string {
if strings.TrimSpace(answer) == "21" {
return "normal"
}
return "degraded"
}
+17 -4
View File
@@ -269,7 +269,14 @@ func selectFromGroupPool(param *RetryParam, tiers []groupPoolTier, filters []dto
// Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1)
// 分组B, 优先级1
func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) {
if RequestRoutingPolicy(param.Ctx) != nil { return selectTokenRoutingPool(param) }
if RequestRoutingPolicy(param.Ctx) != nil {
return selectTokenRoutingPool(param)
}
if group := imageGenerationRequestGroup(param.Ctx, param.ModelName); group != "" {
channel, err := selectWithTrace(param, group, param.GetRetry())
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, group)
return channel, group, err
}
var channel *model.Channel
var err error
selectGroup := param.TokenGroup
@@ -488,10 +495,16 @@ func SelectChannelForRequest(c *gin.Context, modelName string, retry *RetryParam
usingGroup := retry.TokenGroup
if RequestRoutingPolicy(c) != nil {
if channel, group := PreferredPoolChannel(c, modelName); channel != nil && retry.GetRetry() == 0 { return channel,group,nil }
if retry.GetRetry() == 0 {
if channel, group := PreferredPoolChannel(c, modelName); channel != nil {
return channel, group, nil
}
}
channel, group, err := selectTokenRoutingPool(retry)
if err != nil || channel == nil { return nil,group,&ChannelSelectError{StatusCode:http.StatusServiceUnavailable,Code:types.ErrorCodeModelNotFound,Message:"No eligible channel in the group pool",NoAvailableChannel:true} }
return channel,group,nil
if err != nil || channel == nil {
return nil, group, &ChannelSelectError{StatusCode: http.StatusServiceUnavailable, Code: types.ErrorCodeModelNotFound, Message: "No eligible channel in the group pool", NoAvailableChannel: true}
}
return channel, group, nil
}
var channel *model.Channel
var selectGroup string
+9 -8
View File
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
benchmark "github.com/QuantumNous/new-api/pkg/model_benchmark"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/billing_setting"
@@ -36,15 +37,15 @@ func DetectionPricing(userGroup, group, kind string) (DetectionPrice, int, error
return DetectionPrice{}, 0, errors.New("invalid group ratio")
}
price := DetectionPrice{Snapshot: billingexpr.BillingSnapshot{BillingMode: "tiered_expr", ModelName: fingerprint.Model, ExprString: expression, ExprHash: billingexpr.ExprHashString(expression), GroupRatio: ratio, QuotaPerUnit: common.QuotaPerUnit, ExprVersion: 1}}
// Both generation paths restrict request text to 16 KiB and output tokens.
output, calls := 16384, 1
if kind == "fingerprint" {
output, calls = 4096, 3
}
price.Request.Body = []byte(fmt.Sprintf(`{"model":"gpt-6-astra","reasoning":{"effort":"high"},"max_output_tokens":%d}`, output))
if kind == "fingerprint" {
price.Request.Body = []byte(`{"model":"gpt-6-astra","max_output_tokens":4096}`)
output, calls := fingerprint.OutputTokens, fingerprint.ProbeCount
if kind == "benchmark" {
output, calls = benchmark.OutputTokens, benchmark.ProbeCount
} else if kind == "candy" {
output, calls = CandyOutputTokens, 1
} else if kind != "fingerprint" {
return price, 0, errors.New("invalid detection type")
}
price.Request.Body = []byte(fmt.Sprintf(`{"model":"gpt-6-astra","reasoning":{"effort":"low"},"max_output_tokens":%d}`, output))
cost, err := billingexpr.DetectionCostBound(expression, 32768, float64(output))
if err != nil {
return price, 0, err
+1 -1
View File
@@ -52,7 +52,7 @@ func ReconcileAutomaticDetectionSchedules(ctx context.Context, channels []model.
cutoff = 0
}
var results []automaticDetectionResult
if kind == "fingerprint" {
if kind == "modeltrace" {
var tasks []model.SystemTask
if err := model.DB.WithContext(ctx).Select("status", "payload", "state", "updated_at").Where("type = ? AND updated_at >= ?", AstraSamplingTaskType, cutoff).Find(&tasks).Error; err != nil {
return nil, nil, err
+24 -21
View File
@@ -29,9 +29,12 @@ func adaptiveTestDB(t *testing.T) {
func seedAutomaticFingerprint(t *testing.T, id string, at int64, closest string, force bool, running bool) {
t.Helper()
result := fingerprint.Result{Status: "error"}
result := fingerprint.Result{Engine: fingerprint.Version, Status: "error"}
if closest != "" {
result = fingerprint.Result{Status: "inconclusive", Closest: closest, ValidSamples: 1}
result = fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch", Closest: closest, ValidSamples: 3}
if closest == fingerprint.Model {
result.Status = "match"
}
}
finished, status := at, model.SystemTaskStatusSucceeded
if running {
@@ -54,78 +57,78 @@ func TestAdaptiveFingerprintBootstrapRecoveryAndManualIsolation(t *testing.T) {
seedAutomaticFingerprint(t, "normal", 1000, fingerprint.Model, false, false)
seedAutomaticFingerprint(t, "unknown", 2000, "", false, false)
seedAutomaticFingerprint(t, "forced", 2100, "gpt-6-sol", true, false)
due, states, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(2899, 0))
due, states, err := AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(2899, 0))
require.NoError(t, err)
assert.Empty(t, due)
assert.Equal(t, "normal", states[1].LastValid)
assert.Equal(t, 15, states[1].IntervalMinutes)
due, _, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(2900, 0))
due, _, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(2900, 0))
require.NoError(t, err)
require.Len(t, due, 1)
seedAutomaticFingerprint(t, "recovered", 3000, fingerprint.Model, false, false)
_, states, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(3000, 0))
_, states, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(3000, 0))
require.NoError(t, err)
assert.Equal(t, 30, states[1].IntervalMinutes)
seedAutomaticFingerprint(t, "downgraded", 5000, "gpt-6-sol", false, false)
_, states, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(5000, 0))
_, states, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(5000, 0))
require.NoError(t, err)
assert.Equal(t, 15, states[1].IntervalMinutes)
seedAutomaticFingerprint(t, "other-non-astra", 6000, "gpt-5.5", false, false)
_, states, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(6000, 0))
_, states, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(6000, 0))
require.NoError(t, err)
assert.Equal(t, 30, states[1].IntervalMinutes)
// A completed result with a missing clock update is replayed once, not twice.
_, again, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(6000, 0))
_, again, err := AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(6000, 0))
require.NoError(t, err)
assert.Equal(t, states, again)
seedAutomaticFingerprint(t, "abandoned", 7000, "", false, true)
due, _, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(10000, 0))
due, _, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(10000, 0))
require.NoError(t, err)
assert.Empty(t, due)
require.NoError(t, model.DB.Model(&model.SystemTask{}).Where("task_id = ?", "abandoned").Updates(map[string]any{"status": model.SystemTaskStatusFailed, "updated_at": 10000}).Error)
_, states, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(10000, 0))
_, states, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(10000, 0))
require.NoError(t, err)
assert.Equal(t, int64(10900), states[1].NextDueAt)
assert.Equal(t, "degraded", states[1].LastValid)
require.NoError(t, model.DB.Where("type = ?", AstraSamplingTaskType).Delete(&model.SystemTask{}).Error)
_, again, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(10000, 0))
_, again, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(10000, 0))
require.NoError(t, err)
assert.Equal(t, states, again)
}
func TestAdaptivePelicanBootstrapInFlightAndRanges(t *testing.T) {
func TestAdaptiveBenchmarkBootstrapInFlightAndRanges(t *testing.T) {
adaptiveTestDB(t)
ctx := context.Background()
channels := []model.Channel{{Id: 1, Group: "one,shared", Models: fingerprint.Model, Status: common.ChannelStatusEnabled}, {Id: 2, Group: "shared", Models: fingerprint.Model, Status: common.ChannelStatusEnabled}}
require.NoError(t, model.DB.Create(&channels).Error)
runs := []model.DetectionRun{
{ID: "auto1", RequestKey: "a", ChannelID: 1, Kind: "pelican", Status: "succeeded", Quality: "normal", Settled: true, FinishedAt: 1000},
{ID: "auto2", RequestKey: "b", ChannelID: 2, Kind: "pelican", Status: "failed", Settled: true, FinishedAt: 1000},
{ID: "paid", RequestKey: "c", UserID: 1, ChannelID: 1, Kind: "pelican", Status: "succeeded", Quality: "degraded", Settled: true, FinishedAt: 1100},
{ID: "active", RequestKey: "d", ChannelID: 2, Kind: "pelican", Status: "running"},
{ID: "auto1", RequestKey: "a", ChannelID: 1, Kind: "benchmark", Status: "succeeded", Quality: "normal", Settled: true, FinishedAt: 1000},
{ID: "auto2", RequestKey: "b", ChannelID: 2, Kind: "benchmark", Status: "failed", Settled: true, FinishedAt: 1000},
{ID: "paid", RequestKey: "c", UserID: 1, ChannelID: 1, Kind: "benchmark", Status: "succeeded", Quality: "degraded", Settled: true, FinishedAt: 1100},
{ID: "active", RequestKey: "d", ChannelID: 2, Kind: "benchmark", Status: "running"},
}
require.NoError(t, model.DB.Create(&runs).Error)
due, states, err := AutomaticDetectionChannelsDue(ctx, "pelican", time.Unix(3000, 0))
due, states, err := AutomaticDetectionChannelsDue(ctx, "benchmark", time.Unix(3000, 0))
require.NoError(t, err)
require.Len(t, due, 1)
assert.Equal(t, 1, due[0].Id)
assert.Equal(t, "normal", states[1].LastValid)
assert.Equal(t, 30, states[1].IntervalMinutes)
assert.Equal(t, 15, states[2].IntervalMinutes)
ranges, err := DetectionScheduleRanges(ctx, channels, "pelican")
ranges, err := DetectionScheduleRanges(ctx, channels, "benchmark")
require.NoError(t, err)
assert.Equal(t, [2]int{15, 30}, ranges["shared"])
assert.Equal(t, [2]int{30, 30}, ranges["one"])
_, fp, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(3000, 0))
_, fp, err := AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(3000, 0))
require.NoError(t, err)
assert.Equal(t, 30, fp[2].IntervalMinutes)
assert.Equal(t, "", fp[1].LastValid)
require.NoError(t, model.DB.Model(&model.DetectionRun{}).Where("id = ?", "active").Updates(map[string]any{"status": "succeeded", "quality": "degraded", "settled": true, "finished_at": 3000}).Error)
due, states, err = AutomaticDetectionChannelsDue(ctx, "pelican", time.Unix(4799, 0))
due, states, err = AutomaticDetectionChannelsDue(ctx, "benchmark", time.Unix(4799, 0))
require.NoError(t, err)
assert.Equal(t, 30, states[2].IntervalMinutes)
require.Len(t, due, 1)
due, _, err = AutomaticDetectionChannelsDue(ctx, "pelican", time.Unix(4800, 0))
due, _, err = AutomaticDetectionChannelsDue(ctx, "benchmark", time.Unix(4800, 0))
require.NoError(t, err)
assert.Len(t, due, 2)
}
+56 -32
View File
@@ -11,15 +11,16 @@ import (
)
type DetectionHistory struct {
SourceURL string `json:"source_url,omitempty"`
ID string `json:"id"`
At int64 `json:"at"`
Status string `json:"status"`
Quality string `json:"quality"`
UserInitiated bool `json:"user_initiated"`
HasHTML bool `json:"has_html"`
Closest string `json:"closest,omitempty"`
Weight float64 `json:"relative_weight,omitempty"`
ID string `json:"id"`
At int64 `json:"at"`
Status string `json:"status"`
Quality string `json:"quality"`
UserInitiated bool `json:"user_initiated"`
}
type PelicanSummary struct {
type BenchmarkSummary struct {
LatestValid *DetectionHistory `json:"-"`
Channels int `json:"channels"`
IntervalMinutes int `json:"interval_minutes"`
IntervalMaxMinutes int `json:"interval_max_minutes"`
@@ -37,7 +38,8 @@ type ChannelDetectionEvidence struct {
}
type GroupDetectionSummary struct {
ProbeHistory []ProbeHistoryPoint
Pelican *PelicanSummary
Candy *BenchmarkSummary
Benchmark *BenchmarkSummary
Status int
At int64
}
@@ -75,7 +77,8 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
}
groups := map[string][]int{}
byChannel := map[int][]string{}
pelicans := map[string]*PelicanSummary{}
benchmarks := map[string]*BenchmarkSummary{}
candies := map[string]*BenchmarkSummary{}
evidence := map[int]ChannelDetectionEvidence{}
observe := func(id int, at int64, success bool) {
if at > evidence[id].At {
@@ -87,19 +90,27 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
for _, group := range channel.GetGroups() {
groups[group] = append(groups[group], channel.Id)
if ChannelSupportsAstra(&channel) {
if pelicans[group] == nil {
pelicans[group] = &PelicanSummary{IntervalMinutes: 30, WindowHours: 6, History: []DetectionHistory{}}
if benchmarks[group] == nil {
benchmarks[group] = &BenchmarkSummary{IntervalMinutes: 30, WindowHours: 6, History: []DetectionHistory{}}
}
pelicans[group].Channels++
benchmarks[group].Channels++
}
}
}
var tasks []model.SystemTask
ranges, err := DetectionScheduleRanges(ctx, channels, "pelican")
candyRanges, err := DetectionScheduleRanges(ctx, channels, "candy")
if err != nil {
return nil, err
}
for group, summary := range pelicans {
for group, summary := range benchmarks {
r := candyRanges[group]
candies[group] = &BenchmarkSummary{Channels: summary.Channels, IntervalMinutes: r[0], IntervalMaxMinutes: r[1], WindowHours: 6, History: []DetectionHistory{}}
}
var tasks []model.SystemTask
ranges, err := DetectionScheduleRanges(ctx, channels, "benchmark")
if err != nil {
return nil, err
}
for group, summary := range benchmarks {
r := ranges[group]
summary.IntervalMinutes, summary.IntervalMaxMinutes = r[0], r[1]
}
@@ -136,14 +147,6 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
if err = model.DB.WithContext(ctx).Omit("html", "price", "remote_id").Where("created_at >= ?", now.Add(-6*time.Hour).Unix()).Find(&runs).Error; err != nil {
return nil, err
}
var artworkIDs []string
if err = model.DB.WithContext(ctx).Model(&model.DetectionRun{}).Where("created_at >= ? AND html <> ?", now.Add(-6*time.Hour).Unix(), "").Pluck("id", &artworkIDs).Error; err != nil {
return nil, err
}
artwork := map[string]bool{}
for _, id := range artworkIDs {
artwork[id] = true
}
ids := []string{}
for _, run := range runs {
ids = append(ids, run.ID)
@@ -164,6 +167,9 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
}
}
for _, run := range runs {
if run.Kind != "fingerprint" && run.Kind != "benchmark" && run.Kind != "candy" {
continue
}
for _, group := range byChannel[run.ChannelID] {
if cutoff := settings[group].ResetBefore; cutoff > 0 && run.CreatedAt <= cutoff {
continue
@@ -180,6 +186,9 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
if result.Status == "" {
result.Status = "error"
}
if result.Engine != fingerprint.Version {
continue
}
result.Status = AstraSamplingResultStatus(result)
switch result.Status {
case "match":
@@ -192,20 +201,28 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
summary.Errors++
}
summary.History = append(summary.History, AstraSamplingHistory{ID: run.ID, At: run.CreatedAt, Status: result.Status, ValidSamples: result.ValidSamples, Closest: result.Closest, Gap: result.Gap, UserInitiated: true})
if result.ValidSamples > 0 && result.Closest != "" && (result.Status == "match" || result.Status == "mismatch") && (summary.LatestValid == nil || run.CreatedAt > summary.LatestValid.At) {
round := summary.History[len(summary.History)-1]
summary.LatestValid = &round
}
continue
}
s := pelicans[group]
s := benchmarks[group]
if run.Kind == "candy" {
s = candies[group]
}
if s == nil {
continue
}
history := DetectionHistory{ID: run.ID, At: run.CreatedAt, Status: run.Status, Quality: run.Quality, UserInitiated: run.UserID > 0, HasHTML: artwork[run.ID]}
var metadata struct {
SourceURL string `json:"source_url"`
}
history := DetectionHistory{ID: run.ID, At: run.CreatedAt, Status: run.Status, Quality: run.Quality, UserInitiated: run.UserID > 0}
var metadata fingerprint.Result
if common.UnmarshalJsonStr(run.Result, &metadata) == nil {
history.SourceURL = metadata.SourceURL
history.Closest, history.Weight = metadata.Closest, metadata.Weight
}
s.History = append(s.History, history)
if run.Status == "succeeded" && (run.Quality == "normal" || run.Quality == "degraded") && (s.LatestValid == nil || history.At > s.LatestValid.At) {
s.LatestValid = &history
}
switch run.Status {
case "pending", "running":
s.Running++
@@ -236,14 +253,21 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
results := map[string]GroupDetectionSummary{}
for group, ids := range groups {
status, at := DetectionFallbackStatus(ids, evidence, now.Unix())
s := pelicans[group]
s := benchmarks[group]
if s != nil {
sort.SliceStable(s.History, func(i, j int) bool { return s.History[i].At > s.History[j].At })
if len(s.History) > 12 {
s.History = s.History[:12]
}
}
results[group] = GroupDetectionSummary{Pelican: s, Status: status, At: at, ProbeHistory: GroupProbeHistory(ids, tasks)}
candy := candies[group]
if candy != nil {
sort.SliceStable(candy.History, func(i, j int) bool { return candy.History[i].At > candy.History[j].At })
if len(candy.History) > 12 {
candy.History = candy.History[:12]
}
}
results[group] = GroupDetectionSummary{Candy: candy, Benchmark: s, Status: status, At: at, ProbeHistory: GroupProbeHistory(ids, tasks)}
}
return results, nil
}
+1 -1
View File
@@ -29,7 +29,7 @@ type groupAvailabilityState struct {
}
var (
groupAvailabilityMu sync.RWMutex
groupAvailabilityMu sync.RWMutex
groupAvailabilityStates = make(map[string]*groupAvailabilityState)
)
+48
View File
@@ -0,0 +1,48 @@
package service
import (
"sync"
"time"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/gin-gonic/gin"
)
var groupDegradationSnapshot struct {
sync.Mutex
updated time.Time
latest map[string]AstraSamplingHistory
benchmark map[string]DetectionHistory
}
func requestGroupDegraded(c *gin.Context, modelName, group string) bool {
policy := RequestRoutingPolicy(c)
if policy == nil {
return false
}
skip := policy.SkipDegradedGroups && (modelName == fingerprint.Model || policy.SkipDegradedNonAstra)
if policy.Mode == "smart" {
skip = !policy.AllowDegraded && (modelName == fingerprint.Model || policy.SkipDegradedNonAstra)
}
if !skip {
return false
}
if c.GetBool("pool_degradation_unavailable") {
return true
}
latest, found := c.Get("pool_group_fingerprints")
benchmark, _ := c.Get("pool_group_benchmarks")
if !found {
rounds, qualities, err := cachedGroupQuality(c.Request.Context())
if err != nil {
c.Set("pool_degradation_unavailable", true)
return true
}
latest, benchmark = rounds, qualities
c.Set("pool_group_fingerprints", latest)
c.Set("pool_group_benchmarks", benchmark)
}
rounds, _ := latest.(map[string]AstraSamplingHistory)
h, ok := rounds[group]
return (ok && h.ValidSamples > 0 && h.Closest != "" && h.Closest != fingerprint.Model)
}
+234
View File
@@ -0,0 +1,234 @@
package service
import (
"fmt"
"os"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"net/http/httptest"
)
func TestGroupDegradationDatabaseMatrix(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var driver gorm.Dialector
switch dialect {
case "sqlite":
driver = sqlite.Open(":memory:")
case "mysql":
if os.Getenv("TEST_MYSQL_DSN") == "" {
t.Skip("TEST_MYSQL_DSN unset")
}
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
case "postgres":
if os.Getenv("TEST_POSTGRES_DSN") == "" {
t.Skip("TEST_POSTGRES_DSN unset")
}
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("degradation_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
oldDB := model.DB
oldType, oldLogType := common.MainDatabaseType(), common.LogDatabaseType()
model.DB = db
common.SetDatabaseTypes(common.DatabaseType(dialect), oldLogType)
rows := []any{&model.Option{}, &model.Channel{}, &model.SystemTask{}, &model.DetectionSchedule{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.Token{}}
require.NoError(t, db.AutoMigrate(rows...))
t.Cleanup(func() {
for _, row := range rows {
require.NoError(t, db.Migrator().DropTable(row))
}
model.DB = oldDB
common.SetDatabaseTypes(oldType, oldLogType)
groupDegradationSnapshot.updated = time.Time{}
groupDegradationSnapshot.latest = nil
conn, err := db.DB()
require.NoError(t, err)
require.NoError(t, conn.Close())
})
var version string
query := "select version()"
if dialect == "sqlite" {
query = "select sqlite_version()"
}
require.NoError(t, db.Raw(query).Scan(&version).Error)
t.Log("database version:", version)
now := time.Now().Unix()
require.NoError(t, db.Create(&model.Channel{Id: 1, Group: "default", Models: fingerprint.Model, Status: 1, Key: "test-only"}).Error)
body, err := common.Marshal(AstraSamplingState{Rounds: []AstraSamplingRound{{ID: "mismatch", ChannelID: 1, StartedAt: now - 30, FinishedAt: now - 10, Result: fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch", Closest: "gpt-5.5", ValidSamples: 2}}}})
require.NoError(t, err)
require.NoError(t, db.Create(&model.SystemTask{TaskID: "degradation-test", Type: AstraSamplingTaskType, Status: model.SystemTaskStatusSucceeded, CreatedAt: now - 30, State: string(body)}).Error)
policy := &model.TokenRoutingPolicy{Version: 1, Mode: "custom", SkipDegradedGroups: true, Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}}}
encoded, err := common.Marshal(policy)
require.NoError(t, err)
token := model.Token{UserId: 1, Key: "degradation-test-key", RoutingPolicy: string(encoded)}
require.NoError(t, db.Create(&token).Error)
require.NoError(t, db.First(&token, token.Id).Error)
stored, err := token.GetRoutingPolicy()
require.NoError(t, err)
require.True(t, stored.SkipDegradedGroups)
ctx := func() *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set(model.TokenRoutingContextKey, stored)
return c
}
groupDegradationSnapshot.updated = time.Time{}
assert.True(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
smart := ctx()
smartPolicy := &model.TokenRoutingPolicy{Version: 1, Mode: "smart"}
smart.Set(model.TokenRoutingContextKey, smartPolicy)
assert.True(t, requestGroupDegraded(smart, fingerprint.Model, "default"))
smartPolicy.AllowDegraded = true
assert.False(t, requestGroupDegraded(smart, fingerprint.Model, "default"))
smartPolicy.AllowDegraded = false
assert.False(t, requestGroupDegraded(smart, "unmeasured-model", "default"))
manualResult, err := common.Marshal(fingerprint.Result{Engine: fingerprint.Version, Status: "match", Closest: fingerprint.Model, ValidSamples: 2})
require.NoError(t, err)
manual := model.DetectionRun{ID: "manual-recovery", RequestKey: "manual-recovery", ChannelID: 1, Kind: "fingerprint", UserID: 1, CreatedAt: now, Settled: true, Status: "succeeded", Result: string(manualResult)}
require.NoError(t, db.Create(&manual).Error)
groupDegradationSnapshot.updated = time.Time{}
assert.False(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
pelican := model.DetectionRun{ID: "pelican-degraded", RequestKey: "pelican-degraded", ChannelID: 1, Kind: "pelican", CreatedAt: now, Settled: true, Status: "succeeded", Quality: "degraded"}
require.NoError(t, db.Create(&pelican).Error)
groupDegradationSnapshot.updated = time.Time{}
assert.False(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
require.NoError(t, db.Model(&pelican).Update("quality", "normal").Error)
groupDegradationSnapshot.updated = time.Time{}
assert.False(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
require.NoError(t, db.Delete(&manual).Error)
require.NoError(t, model.SaveStatusPageGroup(ctx().Request.Context(), "default", nil))
groupDegradationSnapshot.updated = time.Time{}
assert.False(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
})
}
}
func TestGroupDegradationUsesLatestValidBeforeHistoryTruncation(t *testing.T) {
now := time.Now()
channels := []model.Channel{{Id: 1, Group: "default", Models: fingerprint.Model, Status: 1}}
rounds := []AstraSamplingRound{{ID: "valid", ChannelID: 1, StartedAt: now.Unix() - 100, FinishedAt: now.Unix() - 90, Result: fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch", Closest: "gpt-5.5", ValidSamples: 1}}}
for i := range 15 {
rounds = append(rounds, AstraSamplingRound{ChannelID: 1, StartedAt: now.Unix() - int64(i), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "error"}})
}
body, err := common.Marshal(AstraSamplingState{Rounds: rounds})
require.NoError(t, err)
tasks := []*model.SystemTask{{Status: model.SystemTaskStatusSucceeded, State: string(body)}}
summary := AggregateAstraSampling(channels, tasks, now)["default"]
require.NotNil(t, summary.LatestValid)
assert.Equal(t, "gpt-5.5", summary.LatestValid.Closest)
assert.Len(t, summary.History, 12)
rounds = append(rounds, AstraSamplingRound{ID: "recovered", ChannelID: 1, StartedAt: now.Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "match", Closest: fingerprint.Model, ValidSamples: 2}})
body, err = common.Marshal(AstraSamplingState{Rounds: rounds})
require.NoError(t, err)
tasks[0].State = string(body)
summary = AggregateAstraSampling(channels, tasks, now)["default"]
require.NotNil(t, summary.LatestValid)
assert.Equal(t, fingerprint.Model, summary.LatestValid.Closest)
reset := aggregateAstraSampling(channels, tasks, now, map[string]model.StatusPageGroup{"default": {ResetBefore: now.Unix()}})
assert.Nil(t, reset["default"].LatestValid)
}
func TestGroupDegradationOverridesExistingPoolAffinity(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
createChannelSelectAutoGroupsChannel(t, db, 6401, "default", fingerprint.Model)
createChannelSelectAutoGroupsChannel(t, db, 6402, "vip", fingerprint.Model)
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
previousRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = previousRedis; _ = poolBindings().Purge() })
require.NoError(t, poolBindings().Purge())
config := operation_setting.GetChannelAffinitySetting()
previous := *config
*config = operation_setting.ChannelAffinitySetting{Enabled: true, DefaultTTLSeconds: 3600, SessionMode: "strict", Rules: []operation_setting.ChannelAffinityRule{{Name: "degradation-test", ModelRegex: []string{".*"}, KeySources: []operation_setting.ChannelAffinityKeySource{{Type: "request_header", Key: "Session-Id"}}, SessionMode: "strict"}}}
t.Cleanup(func() { *config = previous })
ctx := func(degraded bool) *gin.Context {
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Request.Header.Set("Session-Id", "degradation-session")
c.Set("id", 100)
c.Set("token_id", 100)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", SkipDegradedGroups: true, Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}, {Group: "vip", Priority: 1}}})
rounds := map[string]AstraSamplingHistory{}
if degraded {
rounds["default"] = AstraSamplingHistory{At: time.Now().Unix(), Closest: "gpt-5.5", ValidSamples: 1}
}
c.Set("pool_group_fingerprints", rounds)
return c
}
first := ctx(false)
selected, group, err := SelectChannelForRequest(first, fingerprint.Model, &RetryParam{Ctx: first, TokenGroup: "default", ModelName: fingerprint.Model})
require.Nil(t, err)
require.NotNil(t, selected)
require.Equal(t, 6401, selected.Id)
first.Set("channel_id", selected.Id)
common.SetContextKey(first, constant.ContextKeyAutoGroup, group)
RequestPolicy(first).Successful = true
RecordPoolAffinity(first)
bound, _ := PreferredPoolChannel(ctx(false), fingerprint.Model)
require.NotNil(t, bound)
next := ctx(true)
selected, group, err = SelectChannelForRequest(next, fingerprint.Model, &RetryParam{Ctx: next, TokenGroup: "default", ModelName: fingerprint.Model})
require.Nil(t, err)
require.NotNil(t, selected)
assert.Equal(t, 6402, selected.Id)
assert.Equal(t, "vip", group)
}
func TestGroupDegradationSkipsPoolGroupsAndRespectsUnknownRecoveryAndOptOut(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
createChannelSelectAutoGroupsChannel(t, db, 6301, "default", fingerprint.Model)
createChannelSelectAutoGroupsChannel(t, db, 6302, "vip", fingerprint.Model)
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
for _, test := range []struct {
name string
enabled bool
closest string
age time.Duration
degradeBackup bool
want int
}{
{"latest mismatch switches", true, "gpt-5.5", time.Minute, false, 6302},
{"recovered group returns", true, fingerprint.Model, time.Minute, false, 6301},
{"unknown stays eligible", true, "", time.Minute, false, 6301},
{"valid result persists beyond window", true, "gpt-5.5", 7 * time.Hour, false, 6302},
{"disabled keeps routing", false, "gpt-5.5", time.Minute, false, 6301},
{"all degraded has no route", true, "gpt-5.5", time.Minute, true, 0},
} {
t.Run(test.name, func(t *testing.T) {
c := setupGroupPoolContext(t)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", SkipDegradedGroups: test.enabled, Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}, {Group: "vip", Priority: 1}}})
rounds := map[string]AstraSamplingHistory{"default": {At: time.Now().Add(-test.age).Unix(), Closest: test.closest, ValidSamples: 1}}
if test.degradeBackup {
rounds["vip"] = rounds["default"]
}
c.Set("pool_group_fingerprints", rounds)
selected, _, err := selectTokenRoutingPool(&RetryParam{Ctx: c, TokenGroup: "default", ModelName: fingerprint.Model})
require.NoError(t, err)
if test.want == 0 {
assert.Nil(t, selected)
return
}
require.NotNil(t, selected)
assert.Equal(t, test.want, selected.Id)
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package service
import (
"context"
"sync"
"time"
"github.com/QuantumNous/new-api/model"
)
var groupModelCostSnapshot struct {
sync.Mutex
updated time.Time
values map[string]map[string]model.GroupModelCost
}
// Immutable snapshots are shared by status display and automatic routing.
func GroupModelCosts(ctx context.Context) (map[string]map[string]model.GroupModelCost, error) {
groupModelCostSnapshot.Lock()
defer groupModelCostSnapshot.Unlock()
now := time.Now()
if groupModelCostSnapshot.values != nil && now.Sub(groupModelCostSnapshot.updated) < time.Minute {
return groupModelCostSnapshot.values, nil
}
queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
values, err := model.GetGroupModelCosts(queryCtx, now.Add(-24*time.Hour), now)
if err != nil {
return nil, err
}
groupModelCostSnapshot.values, groupModelCostSnapshot.updated = values, now
return values, nil
}
+108
View File
@@ -0,0 +1,108 @@
package service
import (
"context"
"errors"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
)
// Routing evidence has no statistical window. Each detector recovers separately.
func readGroupQuality(ctx context.Context, now time.Time) (map[string]AstraSamplingHistory, map[string]DetectionHistory, error) {
settings, err := model.GetStatusPageGroups(ctx)
if err != nil {
return nil, nil, err
}
channels, err := samplingChannels(ctx, false)
if err != nil {
return nil, nil, err
}
channelGroups := map[int][]string{}
for _, channel := range channels {
channelGroups[channel.Id] = channel.GetGroups()
}
latest, benchmarks := map[string]AstraSamplingHistory{}, map[string]DetectionHistory{}
observe := func(channelID int, h AstraSamplingHistory) {
if h.At <= 0 || h.At > now.Unix() || h.ValidSamples <= 0 || h.Closest == "" || (h.Status != "match" && h.Status != "mismatch") {
return
}
for _, group := range channelGroups[channelID] {
if h.At > settings[group].ResetBefore && h.At > latest[group].At {
latest[group] = h
}
}
}
var tasks []model.SystemTask
if err = model.DB.WithContext(ctx).Select("state").Where("type = ?", AstraSamplingTaskType).Find(&tasks).Error; err != nil {
return nil, nil, err
}
for _, task := range tasks {
var state AstraSamplingState
if task.DecodeState(&state) != nil {
continue
}
for _, round := range state.Rounds {
if round.FinishedAt <= 0 || round.Result.Engine != fingerprint.Version {
continue
}
observe(round.ChannelID, AstraSamplingHistory{ID: round.ID, At: round.StartedAt, Status: AstraSamplingResultStatus(round.Result), ValidSamples: round.Result.ValidSamples, Closest: round.Result.Closest})
}
}
var runs []model.DetectionRun
if err = model.DB.WithContext(ctx).Select("id", "channel_id", "kind", "created_at", "quality", "result").Where("status = ? AND settled = ? AND created_at <= ?", "succeeded", true, now.Unix()).Find(&runs).Error; err != nil {
return nil, nil, err
}
for _, run := range runs {
if run.Kind == "fingerprint" {
var result fingerprint.Result
if common.UnmarshalJsonStr(run.Result, &result) != nil || result.Engine != fingerprint.Version {
continue
}
observe(run.ChannelID, AstraSamplingHistory{ID: run.ID, At: run.CreatedAt, Status: AstraSamplingResultStatus(result), ValidSamples: result.ValidSamples, Closest: result.Closest})
}
}
return latest, benchmarks, nil
}
func cachedGroupQuality(ctx context.Context) (map[string]AstraSamplingHistory, map[string]DetectionHistory, error) {
groupDegradationSnapshot.Lock()
defer groupDegradationSnapshot.Unlock()
now := time.Now()
if now.Sub(groupDegradationSnapshot.updated) >= 15*time.Second {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
latest, benchmarks, err := readGroupQuality(ctx, now)
cancel()
if err == nil {
groupDegradationSnapshot.latest, groupDegradationSnapshot.benchmark = latest, benchmarks
} else if groupDegradationSnapshot.latest == nil {
return nil, nil, errors.New("group quality temporarily unavailable")
}
groupDegradationSnapshot.updated = now
}
return groupDegradationSnapshot.latest, groupDegradationSnapshot.benchmark, nil
}
func RecentDegradedGroups(ctx context.Context) (map[string]bool, error) {
latest, _, err := cachedGroupQuality(ctx)
if err != nil {
return nil, err
}
result := map[string]bool{}
cutoff := time.Now().Add(-AstraSamplingWindow).Unix()
for group, h := range latest {
if h.At >= cutoff && h.Closest != fingerprint.Model {
result[group] = true
}
}
return result, nil
}
func InvalidateGroupQualityCache() {
groupDegradationSnapshot.Lock()
defer groupDegradationSnapshot.Unlock()
groupDegradationSnapshot.updated = time.Time{}
}
+100
View File
@@ -0,0 +1,100 @@
package service
import (
"context"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestQualitySwitchesAndFailedReadCache(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
require.NoError(t, db.AutoMigrate(&model.Option{}, &model.SystemTask{}, &model.DetectionRun{}))
createChannelSelectAutoGroupsChannel(t, db, 6501, "default", fingerprint.Model)
old := time.Now().Add(-8 * time.Hour).Unix()
result, err := common.Marshal(fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch", ValidSamples: 3, Closest: "gpt-6-sol"})
require.NoError(t, err)
run := model.DetectionRun{Result: string(result), ID: "old-valid", RequestKey: "old-valid", ChannelID: 6501, Kind: "fingerprint", Status: "succeeded", Settled: true, Quality: "degraded", CreatedAt: old}
require.NoError(t, db.Create(&run).Error)
require.NoError(t, db.Create(&model.DetectionRun{ID: "failed", RequestKey: "failed", ChannelID: 6501, Kind: "pelican", Status: "failed", Settled: true, CreatedAt: time.Now().Unix()}).Error)
groupDegradationSnapshot.latest = nil
InvalidateGroupQualityCache()
t.Cleanup(func() {
groupDegradationSnapshot.latest = nil
groupDegradationSnapshot.benchmark = nil
InvalidateGroupQualityCache()
})
ctx := func(allow, child bool) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart", AllowDegraded: allow, SkipDegradedNonAstra: child})
return c
}
for _, allow := range []bool{false, true} {
for _, child := range []bool{false, true} {
require.Equal(t, !allow, requestGroupDegraded(ctx(allow, child), fingerprint.Model, "default"))
require.Equal(t, !allow && child, requestGroupDegraded(ctx(allow, child), "gpt-6-sol", "default"))
}
}
recent, err := RecentDegradedGroups(context.Background())
require.NoError(t, err)
require.False(t, recent["default"])
require.NoError(t, db.Migrator().DropTable(&model.DetectionRun{}))
InvalidateGroupQualityCache()
require.True(t, requestGroupDegraded(ctx(false, false), fingerprint.Model, "default"), "read failure retains valid conclusion")
groupDegradationSnapshot.latest = nil
InvalidateGroupQualityCache()
cold := ctx(false, false)
require.True(t, requestGroupDegraded(cold, fingerprint.Model, "default"))
require.True(t, cold.GetBool("pool_degradation_unavailable"))
require.NoError(t, db.AutoMigrate(&model.DetectionRun{}))
InvalidateGroupQualityCache()
require.False(t, requestGroupDegraded(ctx(false, false), fingerprint.Model, "default"), "successful empty history remains eligible")
}
func TestAbandonedPoolProbeIsReleasedWithoutClearingFailures(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
createChannelSelectAutoGroupsChannel(t, db, 7001, "vip", "m")
model.InitChannelCache()
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set("id", 701)
c.Set("token_id", 701)
key := poolCooldownKey(c, "m", "default")
require.NoError(t, poolCooldowns().SetWithTTL(key, poolCooldown{Until: time.Now().Add(-time.Second).UnixMilli()}, 0))
t.Cleanup(func() { _, _ = poolCooldowns().DeleteMany([]string{key}) })
require.True(t, claimPoolGroup(c, "m", "default"))
require.True(t, poolGroupSkipped(c, "m", "default"))
ReleasePoolProbes(c)
require.False(t, poolGroupSkipped(c, "m", "default"))
require.True(t, claimPoolGroup(c, "m", "default"))
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart"})
RecordPoolRetryableFailure(c, "m", "default")
ReleasePoolProbes(c)
value, found, err := poolCooldowns().Get(key)
require.NoError(t, err)
require.True(t, found)
require.Greater(t, value.Until, time.Now().UnixMilli())
}
func TestManualPoolNonAstraDegradationSwitch(t *testing.T) {
for _, mode := range []string{"inherit", "custom"} {
for _, enabled := range []bool{false, true} {
for _, nonAstra := range []bool{false, true} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: mode, SkipDegradedGroups: enabled, SkipDegradedNonAstra: nonAstra})
c.Set("pool_group_fingerprints", map[string]AstraSamplingHistory{"default": {Status: "mismatch", ValidSamples: 3, Closest: "gpt-6-sol"}})
c.Set("pool_group_pelicans", map[string]DetectionHistory{"default": {Quality: "degraded"}})
require.Equal(t, enabled, requestGroupDegraded(c, fingerprint.Model, "default"))
require.Equal(t, enabled && nonAstra, requestGroupDegraded(c, "gpt-6-sol", "default"))
require.False(t, requestGroupDegraded(c, "gpt-6-sol", "untested"))
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package service
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"slices"
)
func imageGenerationPoolGroup(c *gin.Context) string {
settings := model_setting.GetGlobalSettings()
group := settings.ImageGenerationGroup
if group == "" || !IsUserSelectableGroup(common.GetContextKeyString(c, constant.ContextKeyUserGroup), group) {
return ""
}
policy := RequestRoutingPolicy(c)
if policy != nil {
if policy.Mode == "user" {
return ""
}
return group
}
if common.GetContextKeyString(c, constant.ContextKeyTokenGroup) == "auto" || common.GetContextKeyBool(c, constant.ContextKeyTokenCrossGroupRetry) {
return group
}
return ""
}
func imageGenerationRequestGroup(c *gin.Context, modelName string) string {
if !slices.Contains(model_setting.GetGlobalSettings().ImageGenerationModels, modelName) {
return ""
}
return imageGenerationPoolGroup(c)
}
// The dedicated route is shared by selection, affinity and the routing preview.
func WithImageGenerationGroup(c *gin.Context, modelName string, entries []model.TokenGroupEntry) []model.TokenGroupEntry {
group := imageGenerationPoolGroup(c)
if group == "" {
return entries
}
if modelName != "" {
if imageGenerationRequestGroup(c, modelName) != "" {
return []model.TokenGroupEntry{{Group: group, Priority: 0}}
}
return entries
}
priority := 0
for _, entry := range entries {
if entry.Group == group {
return entries
}
priority = max(priority, entry.Priority+1)
}
return append(slices.Clone(entries), model.TokenGroupEntry{Group: group, Priority: priority})
}
+55
View File
@@ -0,0 +1,55 @@
package service
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"net/http/httptest"
"testing"
)
func TestDedicatedImageGenerationPoolSelection(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
cfg := model_setting.GetGlobalSettings()
oldGroup, oldModels := cfg.ImageGenerationGroup, cfg.ImageGenerationModels
t.Cleanup(func() { cfg.ImageGenerationGroup, cfg.ImageGenerationModels = oldGroup, oldModels })
cfg.ImageGenerationGroup, cfg.ImageGenerationModels = "example-images", []string{"gpt-image-2"}
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP","example-images":"Images"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":2,"example-images":1.25}`))
createChannelSelectAutoGroupsChannel(t, db, 8001, "default", "gpt-image-2")
createChannelSelectAutoGroupsChannel(t, db, 8002, "example-images", "gpt-image-2")
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
for _, mode := range []string{"smart", "inherit", "custom"} {
t.Run(mode, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/images/generations", nil)
common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: mode, Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}}})
entries := ResolveRequestPool(c, "gpt-image-2")
require.Equal(t, []model.TokenGroupEntry{{Group: "example-images", Priority: 0}}, entries)
channel, group, err := CacheGetRandomSatisfiedChannel(&RetryParam{Ctx: c, TokenGroup: "default", ModelName: "gpt-image-2", Retry: common.GetPointer(0)})
require.NoError(t, err)
require.NotNil(t, channel)
require.Equal(t, 8002, channel.Id)
require.Equal(t, "example-images", group)
})
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/images/generations", nil)
common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
defaults := []model.TokenGroupEntry{{Group: "default", Priority: 0}}
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: defaults})
require.Equal(t, defaults, ResolveRequestPool(c, "text-model"))
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "user"})
require.Equal(t, defaults, ResolveRequestPool(c, "gpt-image-2"))
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: defaults})
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
require.Equal(t, defaults, ResolveRequestPool(c, "gpt-image-2"))
}
+3 -1
View File
@@ -100,7 +100,9 @@ func AppendRelayLogAdminInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) *model.LogOther {
if !ctx.GetBool("attempt_checkpoint") && ctx.GetString("attempt_guard_result") == "" { MarkRequestPolicySuccess(ctx, relayInfo.StreamStatus) }
if !ctx.GetBool("attempt_checkpoint") && ctx.GetString("attempt_guard_result") == "" {
MarkRequestPolicySuccess(ctx, relayInfo.StreamStatus)
}
other := model.NewLogOther()
other.SetPublic("model_ratio", modelRatio)
other.SetPublic("group_ratio", groupRatio)
+247
View File
@@ -0,0 +1,247 @@
package service
import (
"fmt"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/cachex"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/samber/hot"
)
const poolFailoverCooldown = 10 * time.Minute
type poolCooldown struct {
Until int64 `json:"until"`
ProbeUntil int64 `json:"probe_until"`
ProbeID string `json:"probe_id"`
}
var poolCooldownOnce sync.Once
var poolCooldownCache *cachex.HybridCache[poolCooldown]
var poolCooldownMu sync.Mutex
func poolCooldowns() *cachex.HybridCache[poolCooldown] {
poolCooldownOnce.Do(func() {
poolCooldownCache = cachex.NewHybridCache[poolCooldown](cachex.HybridCacheConfig[poolCooldown]{
Namespace: cachex.Namespace("new-api:pool-failover:v3"), Redis: common.RDB,
RedisEnabled: func() bool { return common.RedisEnabled && common.RDB != nil }, RedisCodec: cachex.JSONCodec[poolCooldown]{},
Memory: func() *hot.HotCache[string, poolCooldown] {
return hot.NewHotCache[string, poolCooldown](hot.LRU, 100000).Build()
},
})
})
return poolCooldownCache
}
func poolCooldownKey(c *gin.Context, modelName, group string) string {
return common.GenerateHMAC(fmt.Sprintf("%d:%d:%t:%d:%s:%d:%s", c.GetInt("id"), c.GetInt("token_id"), c.GetBool("pool_request_stream"), len(modelName), modelName, len(group), group))
}
func poolFailures(c *gin.Context) map[string]int {
if value, ok := c.Get("pool_failures"); ok {
return value.(map[string]int)
}
value := map[string]int{}
c.Set("pool_failures", value)
return value
}
// A cooldown may exclude a group only while another eligible route exists.
// Inspect alternatives without claiming probes or recursively checking cooldowns.
func hasPoolAlternative(c *gin.Context, modelName, current string) bool {
for _, entry := range ResolveRequestPool(c, modelName) {
group := entry.Group
if group == current || poolFailures(c)[group] >= 2 || requestGroupDegraded(c, modelName, group) {
continue
}
value, found, err := poolCooldowns().Get(poolCooldownKey(c, modelName, group))
now := time.Now().UnixMilli()
if err != nil || (found && (now < value.Until || now < value.ProbeUntil)) {
continue
}
channel, err := model.GetRandomSatisfiedChannel(group, modelName, 0, GetChannelConstraints(c).Filters)
if err == nil && channel != nil {
return true
}
}
return false
}
func poolGroupSkipped(c *gin.Context, modelName, group string) bool {
if poolFailures(c)[group] >= 2 {
return true
}
if RequestRoutingPolicy(c) != nil && !hasPoolAlternative(c, modelName, group) {
return false
}
value, found, err := poolCooldowns().Get(poolCooldownKey(c, modelName, group))
if err != nil || !found {
return false
}
now := time.Now().UnixMilli()
skipped := now < value.Until || now < value.ProbeUntil
if skipped {
wait := int((max(value.Until, value.ProbeUntil) - now + 999) / 1000)
previous := c.GetInt("pool_retry_after")
if previous == 0 || wait < previous {
c.Set("pool_retry_after", wait)
}
}
return skipped
}
// A request rejected after selection or interrupted while streaming must not
// leave its recovery reservation blocking subsequent requests for five minutes.
func ReleasePoolProbes(c *gin.Context) {
raw, ok := c.Get("pool_claimed_groups")
if !ok {
return
}
for group, key := range raw.(map[string]string) {
observed, ok := c.Get("pool_probe:" + group)
if !ok {
continue
}
trial := observed.(poolCooldown)
poolCooldownMu.Lock()
current, found, err := poolCooldowns().Get(key)
if err == nil && found && current.Until == trial.Until && current.ProbeID == trial.ProbeID {
current.ProbeUntil, current.ProbeID = 0, ""
_ = poolCooldowns().SetWithTTL(key, current, 0)
}
poolCooldownMu.Unlock()
}
}
// Only the selected candidate can claim a half-open probe. The expired marker
// survives the ten-minute cooldown so a failed probe never gets a second try.
func claimPoolGroup(c *gin.Context, modelName, group string) bool {
if RequestRoutingPolicy(c) != nil && !hasPoolAlternative(c, modelName, group) {
return true
}
poolCooldownMu.Lock()
defer poolCooldownMu.Unlock()
key := poolCooldownKey(c, modelName, group)
value, found, err := poolCooldowns().Get(key)
if err != nil || !found {
return true
}
now := time.Now().UnixMilli()
if now < value.Until || now < value.ProbeUntil {
return false
}
value.ProbeUntil = now + int64((5*time.Minute)/time.Millisecond)
value.ProbeID = fmt.Sprint(time.Now().UnixNano())
if err := poolCooldowns().SetWithTTL(key, value, 0); err != nil {
return false
}
c.Set("pool_probe:"+group, value)
raw, ok := c.Get("pool_claimed_groups")
claimed := map[string]string{}
if ok {
claimed = raw.(map[string]string)
}
claimed[group] = key
c.Set("pool_claimed_groups", claimed)
return true
}
func IsPoolFailoverError(c *gin.Context, err *types.NewAPIError) bool {
if _, pinned, _ := GetChannelConstraints(c).ResolvedPin(); pinned {
return false
}
if err == nil || RequestRoutingPolicy(c) == nil || GetChannelConstraints(c).SuppressesRetry() || types.IsSkipRetryError(err) {
return false
}
if c.Request != nil && c.Request.Context().Err() != nil {
return false
}
if c.Writer != nil && c.Writer.Written() {
return false
}
if err.StatusCode < 500 || err.StatusCode > 599 {
return false
}
return !operation_setting.IsAlwaysSkipRetryStatusCode(err.StatusCode) && !operation_setting.IsAlwaysSkipRetryCode(err.GetErrorCode()) && operation_setting.ShouldRetryByStatusCode(err.StatusCode)
}
func RecordPoolRetryableFailure(c *gin.Context, modelName, group string) {
if _, pinned, _ := GetChannelConstraints(c).ResolvedPin(); pinned {
return
}
if RequestRoutingPolicy(c) == nil || group == "" || GetChannelConstraints(c).SuppressesRetry() {
return
}
failures := poolFailures(c)
failures[group]++
c.Set("pool_retry_group", group)
if _, trial := c.Get("pool_probe:" + group); trial {
failures[group] = 2
}
if failures[group] < 2 || !hasPoolAlternative(c, modelName, group) {
return
}
poolCooldownMu.Lock()
key := poolCooldownKey(c, modelName, group)
previous, _, _ := poolCooldowns().Get(key)
value := poolCooldown{Until: max(previous.Until+1, time.Now().Add(poolFailoverCooldown).UnixMilli())}
if err := poolCooldowns().SetWithTTL(key, value, 0); err != nil {
common.SysError("pool cooldown write failed: " + err.Error())
}
poolCooldownMu.Unlock()
ClearPoolAffinity(c)
}
func RecordPoolSuccess(c *gin.Context, modelName, group string) {
raw, trial := c.Get("pool_probe:" + group)
if !trial {
return
}
observed := raw.(poolCooldown)
poolCooldownMu.Lock()
defer poolCooldownMu.Unlock()
key := poolCooldownKey(c, modelName, group)
current, found, err := poolCooldowns().Get(key)
if err == nil && found && current.Until == observed.Until && current.ProbeID == observed.ProbeID {
_, _ = poolCooldowns().DeleteMany([]string{key})
}
}
func PoolRetryBudget(c *gin.Context, modelName string) int {
if _, pinned, _ := GetChannelConstraints(c).ResolvedPin(); pinned {
return 0
}
if RequestRoutingPolicy(c) == nil || GetChannelConstraints(c).SuppressesRetry() {
return 0
}
return max(0, 2*len(ResolveRequestPool(c, modelName))-1)
}
func poolHigherPriorityRecovered(c *gin.Context, modelName, boundGroup string) bool {
entries := ResolveRequestPool(c, modelName)
priority := -1
for _, entry := range entries {
if entry.Group == boundGroup {
priority = entry.Priority
break
}
}
for _, entry := range entries {
if entry.Priority >= priority {
break
}
value, found, err := poolCooldowns().Get(poolCooldownKey(c, modelName, entry.Group))
if err == nil && found && time.Now().UnixMilli() >= value.Until && !poolGroupSkipped(c, modelName, entry.Group) && !requestGroupDegraded(c, modelName, entry.Group) {
return true
}
}
return false
}
func PoolRetryExhausted(c *gin.Context, modelName, group string) bool {
return poolFailures(c)[group] >= 2 && !hasPoolAlternative(c, modelName, group)
}
+95
View File
@@ -0,0 +1,95 @@
package service
import (
"context"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"net/http/httptest"
"testing"
"time"
)
func TestPoolWithoutAvailableBackupDoesNotCoolDown(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
createChannelSelectAutoGroupsChannel(t, db, 7801, "default", "m")
createChannelSelectAutoGroupsChannel(t, db, 7802, "vip", "other")
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(func() { ResetGroupAvailability(); _ = poolCooldowns().Purge() })
makeCtx := func() *gin.Context {
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set("token_id", 7801)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: []model.TokenGroupEntry{{Group: "default"}, {Group: "vip", Priority: 1}}})
return c
}
c := makeCtx()
RecordPoolRetryableFailure(c, "m", "default")
RecordPoolRetryableFailure(c, "m", "default")
_, found, err := poolCooldowns().Get(poolCooldownKey(c, "m", "default"))
require.NoError(t, err)
require.False(t, found)
// An old cooldown and global health state must not strand a single eligible group.
require.NoError(t, poolCooldowns().SetWithTTL(poolCooldownKey(c, "m", "default"), poolCooldown{Until: time.Now().Add(10 * time.Minute).UnixMilli()}, 0))
for i := 0; i < 4; i++ {
RecordGroupOutcome("default", false)
}
next := makeCtx()
ch, group, err := selectTokenRoutingPool(&RetryParam{Ctx: next, ModelName: "m", Retry: common.GetPointer(0)})
require.NoError(t, err)
require.NotNil(t, ch)
require.Equal(t, "default", group)
AppendUsedChannel(next, ch.Id)
RecordPoolRetryableFailure(next, "m", "default")
ch, _, err = selectTokenRoutingPool(&RetryParam{Ctx: next, ModelName: "m", Retry: common.GetPointer(1)})
require.NoError(t, err)
require.NotNil(t, ch)
AppendUsedChannel(next, ch.Id)
RecordPoolRetryableFailure(next, "m", "default")
ch, _, err = selectTokenRoutingPool(&RetryParam{Ctx: next, ModelName: "m", Retry: common.GetPointer(2)})
require.NoError(t, err)
require.Nil(t, ch, "do not loop indefinitely inside one request")
}
func TestAutomaticSpeedUsesLatestExactModelStreamingTest(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
require.NoError(t, db.AutoMigrate(&model.Log{}))
previous := model.LOG_DB
model.LOG_DB = db
t.Cleanup(func() { model.LOG_DB = previous })
now := time.Now().Unix()
samples := []model.Log{
{ChannelId: 1, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now - 20, Other: `{"frt":500}`},
{ChannelId: 1, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now - 10, Other: `{"frt":60000}`},
{ChannelId: 1, Type: 2, TokenId: 9, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now, Other: `{"frt":1}`},
{ChannelId: 2, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now - 5, Other: `{"frt":1200}`},
{ChannelId: 2, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "other", IsStream: true, CreatedAt: now, Other: `{"frt":10}`},
{ChannelId: 3, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: false, CreatedAt: now, Other: `{"frt":10}`},
{ChannelId: 4, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now - 2*3600, Other: `{"frt":10}`},
}
require.NoError(t, db.Create(&samples).Error)
speeds := latestModelTestSpeeds(context.Background(), "m")
require.Equal(t, map[int]float64{1: 60000, 2: 1200}, speeds)
}
func TestProbeAndStreamingCooldownsAreIsolated(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
createChannelSelectAutoGroupsChannel(t, db, 7901, "default", "m")
createChannelSelectAutoGroupsChannel(t, db, 7902, "vip", "m")
model.InitChannelCache()
t.Cleanup(func() { _ = poolCooldowns().Purge() })
makeCtx := func(stream bool) *gin.Context {
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set("token_id", 7901)
c.Set("pool_request_stream", stream)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: []model.TokenGroupEntry{{Group: "default"}, {Group: "vip", Priority: 1}}})
return c
}
probe := makeCtx(false)
RecordPoolRetryableFailure(probe, "m", "default")
RecordPoolRetryableFailure(probe, "m", "default")
require.True(t, poolGroupSkipped(makeCtx(false), "m", "default"))
require.False(t, poolGroupSkipped(makeCtx(true), "m", "default"))
}
+1 -15
View File
@@ -7,7 +7,6 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
@@ -103,21 +102,8 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
textOutTokens := usage.OutputTokenDetails.TextTokens
audioInputTokens := usage.InputTokenDetails.AudioTokens
audioOutTokens := usage.OutputTokenDetails.AudioTokens
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
modelRatio, _, _ := ratio_setting.GetModelRatio(modelName)
autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup)
if exists {
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
logger.LogDebug(ctx, "final group ratio: %f", groupRatio)
relayInfo.UsingGroup = autoGroup.(string)
}
actualGroupRatio := groupRatio
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
if ok {
actualGroupRatio = userGroupRatio
}
actualGroupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
+7 -1
View File
@@ -22,7 +22,13 @@ func DecideRelayRetry(c *gin.Context, err *types.NewAPIError, retryTimes int) Po
if err == nil {
return PolicyDecision{Action: "stop", Reason: "request_completed", Source: "system"}
}
if ShouldSkipRetryAfterChannelAffinityFailure(c) {
if c.Request != nil && c.Request.Context().Err() != nil {
return PolicyDecision{Action: "stop", Reason: "client_cancelled", Source: "system"}
}
if c.Writer != nil && c.Writer.Written() {
return PolicyDecision{Action: "stop", Reason: "response_started", Source: "system"}
}
if ShouldSkipRetryAfterChannelAffinityFailure(c) && !IsPoolFailoverError(c, err) {
source := RequestPolicy(c).SessionModeSource
if source == "" {
source = "session_rule"
+11 -10
View File
@@ -11,19 +11,20 @@ import (
)
type RequestDetails struct {
SessionID string `json:"session_id,omitempty"`
SessionSource string `json:"session_source"`
CorrelationKey string `json:"correlation_key,omitempty"`
RequestedModel string `json:"requested_model,omitempty"`
UpstreamModel string `json:"upstream_model,omitempty"`
ReturnedModel string `json:"returned_model,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Status int `json:"status"`
ModelGuard *relaycommon.ResponseModelGuard `json:"model_guard,omitempty"`
RoutingPolicyFingerprint string `json:"routing_policy_fingerprint,omitempty"`
SessionID string `json:"session_id,omitempty"`
SessionSource string `json:"session_source"`
CorrelationKey string `json:"correlation_key,omitempty"`
RequestedModel string `json:"requested_model,omitempty"`
UpstreamModel string `json:"upstream_model,omitempty"`
ReturnedModel string `json:"returned_model,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Status int `json:"status"`
ModelGuard *relaycommon.ResponseModelGuard `json:"model_guard,omitempty"`
}
func appendRequestDetails(c *gin.Context, info *relaycommon.RelayInfo, other *model.LogOther) {
d := RequestDetails{SessionSource: "none", RequestedModel: c.GetString("original_model"), Status: c.Writer.Status()}
d := RequestDetails{RoutingPolicyFingerprint: c.GetString("pool_affinity_policy_fingerprint"), SessionSource: "none", RequestedModel: c.GetString("original_model"), Status: c.Writer.Status()}
if status, ok := other.Snapshot()["status_code"].(int); ok {
d.Status = status
}
+3
View File
@@ -145,6 +145,9 @@ func MarkRequestPolicySuccess(c *gin.Context, stream *relaycommon.StreamStatus)
}
state.OutcomeRecorded = true
state.Successful = stream == nil || stream.IsNormalEnd() && !stream.HasErrors() && (stream.ResponseOutcome() == "" || stream.ResponseOutcome() == "completed")
if state.Successful {
RecordPoolSuccess(c, c.GetString("original_model"), common.GetContextKeyString(c, constant.ContextKeyAutoGroup))
}
decision := PolicyDecision{Action: "success", Reason: "request_completed", Source: "upstream"}
if !state.Successful {
decision = PolicyDecision{Action: "stop", Reason: "stream_not_successful", Source: "system"}
+7 -3
View File
@@ -21,7 +21,7 @@ type ResponsesUsageAccumulator struct {
imageCounter relaycommon.ImageGenerationCallCounter
imageCommitted bool
started bool
hasUsage bool
hasUsage bool
finished bool
}
@@ -42,7 +42,9 @@ func (a *ResponsesUsageAccumulator) Observe(event *dto.ResponsesStreamResponse)
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
if event.Response != nil {
ApplyResponsesUsage(a.usage, event.Response.Usage)
if event.Response.Usage!=nil {a.hasUsage=true}
if event.Response.Usage != nil {
a.hasUsage = true
}
if a.outputText.Len() == 0 {
// Some upstreams carry the output only on the terminal event.
a.outputText.WriteString(relayconvert.ExtractOutputTextFromResponses(event.Response))
@@ -86,7 +88,9 @@ func (a *ResponsesUsageAccumulator) Finish() *dto.Usage {
return a.usage
}
a.finished = true
if a.info.DurableAttemptBilling && !a.hasUsage {a.usage.UsageSource="estimated"}
if a.info.DurableAttemptBilling && !a.hasUsage {
a.usage.UsageSource = "estimated"
}
// A final image item can already have reached the client before the stream
// disconnects. Explicit failed/incomplete terminals reset and commit zero in
// Observe; otherwise retain completed tool usage even without a terminal.
+5
View File
@@ -369,6 +369,11 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
finalGroupRatio = groupRatio
}
// The submitted task retains its actual group price, including a zero override.
if snapshot := task.PrivateData.BillingContext; snapshot != nil {
finalGroupRatio = snapshot.GroupRatio
}
// 计算 OtherRatios 乘积(视频折扣、时长等)
otherMultiplier := 1.0
if priceData := taskBillingContextPriceData(task.PrivateData.BillingContext); priceData != nil {
+22
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
@@ -1744,3 +1745,24 @@ func TestSettle_TokenRecalcFallsBackToCompletionTokens(t *testing.T) {
})
}
}
func TestRecalculateTaskTokensKeepsPersonalSnapshot(t *testing.T) {
previous := ratio_setting.ModelRatio2JSONString()
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"personal-task":1}`))
t.Cleanup(func() { require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(previous)) })
for _, ratio := range []float64{0.2, 0} {
t.Run(fmt.Sprint(ratio), func(t *testing.T) {
truncate(t)
seedUser(t, 81, 10000)
seedToken(t, 81, 81, "personal-task-token", 5000)
seedChannel(t, 81)
seedChargedAccounting(t, 81, 81, 81, 1000, 1)
task := makeTask(81, 81, 1000, 81, BillingSourceWallet, 0)
task.Group = "default"
task.PrivateData.BillingContext = &model.TaskBillingContext{OriginModelName: "personal-task", GroupRatio: ratio}
require.True(t, RecalculateTaskQuotaByTokens(context.Background(), task, 1000))
require.Equal(t, int(1000*ratio), task.Quota)
require.Equal(t, 10000+1000-int(1000*ratio), getUserQuota(t, 81))
})
}
}
+8 -4
View File
@@ -458,9 +458,9 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
}
if relayInfo.DurableAttemptID == 0 {
if err := SettleBilling(ctx, relayInfo, summary.Quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
if err := SettleBilling(ctx, relayInfo, summary.Quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
}
logModel := summary.ModelName
@@ -549,6 +549,10 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
Group: relayInfo.UsingGroup,
Other: other,
}
if relayInfo.DurableAttemptID != 0 { recordDurableAttempt(ctx,relayInfo,billingUsage,logParams) } else { model.RecordConsumeLog(ctx,relayInfo.UserId,logParams) }
if relayInfo.DurableAttemptID != 0 {
recordDurableAttempt(ctx, relayInfo, billingUsage, logParams)
} else {
model.RecordConsumeLog(ctx, relayInfo.UserId, logParams)
}
relayInfo.PerformanceOutputTokens = int64(summary.CompletionTokens)
}
+3 -1
View File
@@ -177,7 +177,9 @@ func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon
if snap == nil {
return nil
}
if relayInfo.DurableAttemptBilling { return nil }
if relayInfo.DurableAttemptBilling {
return nil
}
if snap.GroupRatio == 0 {
// Paid-to-free keeps FreeModel as-is: FreeModel means "pre-consume was
// skipped", which is not true once a session exists, and settlement
+77 -6
View File
@@ -1,6 +1,7 @@
package service
import (
"crypto/sha256"
"fmt"
"strconv"
"strings"
@@ -17,8 +18,9 @@ import (
)
type poolBinding struct {
Channel int `json:"channel"`
Group string `json:"group"`
PolicyFingerprint string `json:"policy_fingerprint"`
Channel int `json:"channel"`
Group string `json:"group"`
}
var poolBindingOnce sync.Once
@@ -44,6 +46,18 @@ func PreferredPoolChannel(c *gin.Context, modelName string) (*model.Channel, str
key := common.GenerateHMAC(fmt.Sprintf("%d:%d:%d:%s:%s", c.GetInt("id"), c.GetInt("token_id"), len(modelName), modelName, meta.CacheKey))
c.Set("pool_affinity_key", key)
c.Set("pool_affinity_ttl", meta.TTLSeconds)
// Snapshot the routing policy before selection. An older in-flight request
// may finish after a manual switch, but its binding must not override the
// new policy on the next request.
policyJSON, err := common.Marshal(struct {
Policy *model.TokenRoutingPolicy
Pool []model.TokenGroupEntry
}{RequestRoutingPolicy(c), ResolveRequestPool(c, modelName)})
if err != nil {
return nil, ""
}
policyFingerprint := fmt.Sprintf("%x", sha256.Sum256(policyJSON))
c.Set("pool_affinity_policy_fingerprint", policyFingerprint)
binding, found, err := poolBindings().Get(key)
if err != nil {
return nil, ""
@@ -61,6 +75,11 @@ func PreferredPoolChannel(c *gin.Context, modelName string) (*model.Channel, str
return nil, ""
}
var other struct {
Admin struct {
Details struct {
PolicyFingerprint string `json:"routing_policy_fingerprint"`
} `json:"request_details"`
} `json:"admin_info"`
Result string `json:"model_guard_result"`
Stream struct {
Status string `json:"status"`
@@ -75,15 +94,33 @@ func PreferredPoolChannel(c *gin.Context, modelName string) (*model.Channel, str
if previous.IsStream && (other.Stream.Status != "ok" || (other.Stream.EndReason != "done" && other.Stream.EndReason != "eof") || (other.Stream.Response != "" && other.Stream.Response != "completed")) {
return nil, ""
}
binding = poolBinding{Channel: previous.ChannelId, Group: previous.Group}
binding = poolBinding{Channel: previous.ChannelId, Group: previous.Group, PolicyFingerprint: other.Admin.Details.PolicyFingerprint}
}
// Unversioned legacy bindings cannot prove they belong to this policy.
// This check also prevents historical logs from restoring an old route.
if binding.PolicyFingerprint != policyFingerprint {
ClearPoolAffinity(c)
return nil, ""
}
for _, entry := range ResolveRequestPool(c, modelName) {
if entry.Group != binding.Group {
continue
}
if requestGroupDegraded(c, modelName, entry.Group) {
ClearPoolAffinity(c)
recordDirectSelection(c, nil, binding.Channel, entry.Group, modelName, "pool_group_degraded")
return nil, ""
}
if poolGroupSkipped(c, modelName, entry.Group) || poolHigherPriorityRecovered(c, modelName, entry.Group) {
ClearPoolAffinity(c)
return nil, ""
}
channel, err := model.CacheGetChannel(binding.Channel)
if err == nil && channel.Status == common.ChannelStatusEnabled && model.IsChannelEnabledForGroupModel(entry.Group, modelName, channel.Id) {
if ok, _ := model.ChannelSatisfiesFilters(channel, modelName, GetChannelConstraints(c).Filters); ok {
if !claimPoolGroup(c, modelName, entry.Group) {
return nil, ""
}
common.SetContextKey(c, constant.ContextKeyAutoGroup, entry.Group)
MarkChannelAffinityUsed(c, entry.Group, channel.Id)
recordDirectSelection(c, channel, channel.Id, entry.Group, modelName, "pool_affinity_hit")
@@ -112,6 +149,7 @@ func RecordPoolAffinity(c *gin.Context) {
if !RequestPolicy(c).Successful {
return
}
RecordPoolSuccess(c, c.GetString("original_model"), common.GetContextKeyString(c, constant.ContextKeyAutoGroup))
key := c.GetString("pool_affinity_key")
if key == "" {
return
@@ -120,7 +158,7 @@ func RecordPoolAffinity(c *gin.Context) {
if ttl <= 0 {
ttl = 3600
}
_ = poolBindings().SetWithTTL(key, poolBinding{Channel: c.GetInt("channel_id"), Group: common.GetContextKeyString(c, constant.ContextKeyAutoGroup)}, time.Duration(ttl)*time.Second)
_ = poolBindings().SetWithTTL(key, poolBinding{Channel: c.GetInt("channel_id"), Group: common.GetContextKeyString(c, constant.ContextKeyAutoGroup), PolicyFingerprint: c.GetString("pool_affinity_policy_fingerprint")}, time.Duration(ttl)*time.Second)
}
func RequestRoutingPolicy(c *gin.Context) *model.TokenRoutingPolicy {
@@ -135,11 +173,14 @@ func ResolveRequestPool(c *gin.Context, modelName string) []model.TokenGroupEntr
return nil
}
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
if policy.Mode == "smart" {
return WithImageGenerationGroup(c, modelName, resolveAutomaticPool(c, modelName))
}
inherited := GetUserAutoGroup(userGroup)
if policy.Mode == "user" {
inherited = []string{userGroup}
}
return policy.Resolve(modelName, inherited, func(group string) bool { return IsUserSelectableGroup(userGroup, group) })
return WithImageGenerationGroup(c, modelName, policy.Resolve(modelName, inherited, func(group string) bool { return IsUserSelectableGroup(userGroup, group) }))
}
func selectTokenRoutingPool(param *RetryParam) (*model.Channel, string, error) {
@@ -152,6 +193,25 @@ func selectTokenRoutingPool(param *RetryParam) (*model.Channel, string, error) {
excluded = append(excluded, id)
}
filters = append(filters, dto.ChannelFilter{Kind: dto.FilterAttemptedChannel, ExcludedChannelIDs: excluded})
// Retry once in the current group, including single-channel groups. Preserve
// every security/protocol filter; remove only the request's attempted list.
if retryGroup := param.Ctx.GetString("pool_retry_group"); retryGroup != "" && poolFailures(param.Ctx)[retryGroup] == 1 && !poolGroupSkipped(param.Ctx, param.ModelName, retryGroup) && !requestGroupDegraded(param.Ctx, param.ModelName, retryGroup) {
param.Ctx.Set("pool_retry_group", "")
for _, entry := range entries {
if entry.Group != retryGroup {
continue
}
channel, err := model.GetRandomSatisfiedChannel(retryGroup, param.ModelName, 0, constraints.Filters)
if err != nil {
return nil, retryGroup, err
}
if channel != nil {
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, retryGroup)
recordDirectSelection(param.Ctx, channel, channel.Id, retryGroup, param.ModelName, "pool_same_group_retry")
return channel, retryGroup, nil
}
}
}
for start := 0; start < len(entries); {
end := start + 1
for end < len(entries) && entries[end].Priority == entries[start].Priority {
@@ -165,7 +225,12 @@ func selectTokenRoutingPool(param *RetryParam) (*model.Channel, string, error) {
offset := nextTierIndex(fmt.Sprintf("pool:%d:%s:%s", param.Ctx.GetInt("token_id"), param.ModelName, strings.Join(names, ",")), len(groups))
for i := range len(groups) {
group := groups[(offset+i)%len(groups)].Group
if !IsGroupAvailable(group) {
if poolGroupSkipped(param.Ctx, param.ModelName, group) {
recordDirectSelection(param.Ctx, nil, 0, group, param.ModelName, "pool_group_cooldown")
continue
}
if requestGroupDegraded(param.Ctx, param.ModelName, group) {
recordDirectSelection(param.Ctx, nil, 0, group, param.ModelName, "pool_group_degraded")
continue
}
trace := &model.SelectionTrace{}
@@ -174,6 +239,9 @@ func selectTokenRoutingPool(param *RetryParam) (*model.Channel, string, error) {
return nil, group, err
}
if channel != nil {
if !claimPoolGroup(param.Ctx, param.ModelName, group) {
continue
}
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, group)
recordDirectSelection(param.Ctx, channel, channel.Id, group, param.ModelName, "model_pool_priority")
return channel, group, nil
@@ -181,5 +249,8 @@ func selectTokenRoutingPool(param *RetryParam) (*model.Channel, string, error) {
}
start = end
}
if param.Ctx.GetBool("pool_degradation_unavailable") {
return nil, param.TokenGroup, fmt.Errorf("group quality temporarily unavailable")
}
return nil, param.TokenGroup, nil
}
+116
View File
@@ -0,0 +1,116 @@
package service
import (
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPoolAffinityManualRoutingSwitch(t *testing.T) {
for _, change := range []string{"priority", "model_rule"} {
t.Run(change, func(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "manual-switch-model"
createChannelSelectAutoGroupsChannel(t, db, 6201, "default", modelName)
createChannelSelectAutoGroupsChannel(t, db, 6202, "vip", modelName)
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
previousRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = previousRedis })
require.NoError(t, poolBindings().Purge())
t.Cleanup(func() { _ = poolBindings().Purge() })
config := operation_setting.GetChannelAffinitySetting()
previousConfig := *config
*config = operation_setting.ChannelAffinitySetting{Enabled: true, DefaultTTLSeconds: 3600, SessionMode: "strict", Rules: []operation_setting.ChannelAffinityRule{{Name: "switch-test", ModelRegex: []string{".*"}, KeySources: []operation_setting.ChannelAffinityKeySource{{Type: "request_header", Key: "Session-Id"}}, SessionMode: "strict"}}}
t.Cleanup(func() { *config = previousConfig })
previousLogDB := model.LOG_DB
model.LOG_DB = db
t.Cleanup(func() { model.LOG_DB = previousLogDB })
require.NoError(t, db.AutoMigrate(&model.Log{}))
ctx := func(switched bool) *gin.Context {
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Request.Header.Set("Session-Id", "same-session")
c.Set("id", 100)
c.Set("token_id", 100)
c.Set("original_model", modelName)
policy := &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}, {Group: "vip", Priority: 1}}}
if switched {
if change == "priority" {
policy.Groups[0].Priority = 1
policy.Groups[1].Priority = 0
} else {
policy.ModelRules = []model.ModelRoutingRule{{Model: modelName, Groups: []model.TokenGroupEntry{{Group: "vip", Priority: 0}}}}
}
}
c.Set(model.TokenRoutingContextKey, policy)
return c
}
old := ctx(false)
ch, _, selectErr := SelectChannelForRequest(old, modelName, &RetryParam{Ctx: old, TokenGroup: "default", ModelName: modelName, Retry: common.GetPointer(0)})
require.Nil(t, selectErr)
require.NotNil(t, ch)
assert.Equal(t, 6201, ch.Id)
old.Set("channel_id", ch.Id)
common.SetContextKey(old, constant.ContextKeyAutoGroup, "default")
RequestPolicy(old).Successful = true
RecordPoolAffinity(old)
unchanged, _ := PreferredPoolChannel(ctx(false), modelName)
require.NotNil(t, unchanged)
assert.Equal(t, 6201, unchanged.Id)
details := model.NewLogOther()
appendRequestDetails(old, nil, details)
body, err := common.Marshal(details.Snapshot())
require.NoError(t, err)
log := model.Log{UserId: 100, TokenId: 100, ModelName: modelName, CorrelationKey: old.GetString("log_correlation_key"), Type: model.LogTypeConsume, CreatedAt: time.Now().Unix(), ChannelId: 6201, Group: "default", Other: string(body)}
require.NoError(t, db.Create(&log).Error)
// The old group is still eligible: only its priority/preference changed.
switched := ctx(true)
selected, group, selectErr := SelectChannelForRequest(switched, modelName, &RetryParam{Ctx: switched, TokenGroup: "default", ModelName: modelName, Retry: common.GetPointer(0)})
require.Nil(t, selectErr)
require.NotNil(t, selected)
assert.Equal(t, 6202, selected.Id)
assert.Equal(t, "vip", group)
switched.Set("channel_id", selected.Id)
common.SetContextKey(switched, constant.ContextKeyAutoGroup, group)
RequestPolicy(switched).Successful = true
RecordPoolAffinity(switched)
unchanged, _ = PreferredPoolChannel(ctx(true), modelName)
require.NotNil(t, unchanged)
assert.Equal(t, 6202, unchanged.Id)
// A request started before the edit may finish after the new one.
RecordPoolAffinity(old)
stale, _ := PreferredPoolChannel(ctx(true), modelName)
assert.Nil(t, stale)
// Cache expiry/restart must not resurrect the old successful log.
require.NoError(t, poolBindings().Purge())
stale, _ = PreferredPoolChannel(ctx(true), modelName)
assert.Nil(t, stale)
// A matching historical policy remains recoverable after cache expiry.
require.NoError(t, poolBindings().Purge())
recovered, _ := PreferredPoolChannel(ctx(false), modelName)
require.NotNil(t, recovered)
assert.Equal(t, 6201, recovered.Id)
// Legacy bindings carry no policy and must be selected afresh once.
require.NoError(t, poolBindings().SetWithTTL(old.GetString("pool_affinity_key"), poolBinding{Channel: 6201, Group: "default"}, time.Hour))
legacy, _ := PreferredPoolChannel(ctx(false), modelName)
assert.Nil(t, legacy)
})
}
}
+144
View File
@@ -1,16 +1,160 @@
package service
import (
"context"
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
apiTypes "github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"math"
"net/http/httptest"
"slices"
"testing"
"time"
)
func TestTokenRoutingSingleChannelRetryCooldownAndRecovery(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
createChannelSelectAutoGroupsChannel(t, db, 6911, "default", "retry-model")
createChannelSelectAutoGroupsChannel(t, db, 6912, "vip", "retry-model")
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(func() { ResetGroupAvailability(); _ = poolCooldowns().Purge() })
makeCtx := func(token int) *gin.Context {
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set("id", 99)
c.Set("token_id", token)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}, {Group: "vip", Priority: 1}}})
return c
}
selectID := func(c *gin.Context) int {
t.Helper()
ch, _, err := selectTokenRoutingPool(&RetryParam{Ctx: c, ModelName: "retry-model", Retry: common.GetPointer(0)})
require.NoError(t, err)
require.NotNil(t, ch)
AppendUsedChannel(c, ch.Id)
return ch.Id
}
c := makeCtx(991)
require.Equal(t, 6911, selectID(c))
RecordPoolRetryableFailure(c, "retry-model", "default")
require.Equal(t, 6911, selectID(c), "a one-channel group must actually get its second attempt")
RecordPoolRetryableFailure(c, "retry-model", "default")
require.Equal(t, 6912, selectID(c))
require.Equal(t, 6912, selectID(makeCtx(991)))
require.Equal(t, 6911, selectID(makeCtx(992)))
key := poolCooldownKey(c, "retry-model", "default")
state, found, err := poolCooldowns().Get(key)
require.NoError(t, err)
require.True(t, found)
require.InDelta(t, int64((10*time.Minute)/time.Millisecond), state.Until-time.Now().UnixMilli(), 2000)
require.NoError(t, poolCooldowns().SetWithTTL(key, poolCooldown{Until: time.Now().Add(-time.Second).UnixMilli()}, 0))
trial := makeCtx(991)
require.Equal(t, 6911, selectID(trial))
require.Equal(t, 6912, selectID(makeCtx(991)), "concurrent request waits for recovery probe")
RecordPoolRetryableFailure(trial, "retry-model", "default")
require.Equal(t, 6912, selectID(trial), "failed probe switches without another primary attempt")
require.NoError(t, poolCooldowns().SetWithTTL(key, poolCooldown{Until: time.Now().Add(-time.Second).UnixMilli()}, 0))
recovery := makeCtx(991)
require.Equal(t, 6911, selectID(recovery))
RecordPoolSuccess(recovery, "retry-model", "default")
_, found, err = poolCooldowns().Get(key)
require.NoError(t, err)
require.False(t, found)
next := makeCtx(991)
require.Equal(t, 6911, selectID(next))
RecordPoolRetryableFailure(next, "retry-model", "default")
require.Equal(t, 6911, selectID(next), "recovered group returns to two-attempt behavior")
mixed := makeCtx(993)
require.Equal(t, 6911, selectID(mixed))
RecordPoolRetryableFailure(mixed, "retry-model", "default")
require.Equal(t, 6911, selectID(mixed))
require.Equal(t, 6912, selectID(mixed), "the one same-group retry must not repeat when a different error triggers another selection")
}
func TestAutomaticGroupPreferenceRanking(t *testing.T) {
original := []automaticCandidate{{group: "cheap", cost: .1, ttft: 900, success: 90, ratio: .2}, {group: "fast", cost: .2, ttft: 100, success: 95, ratio: .3}, {group: "stable", cost: .3, ttft: 400, success: 99, ratio: .4}, {group: "unknown", cost: math.Inf(1), ttft: math.Inf(1), success: -1, ratio: .01}}
for preference, want := range map[string]string{"cost": "unknown", "speed": "fast", "stable": "stable"} {
t.Run(preference, func(t *testing.T) {
items := slices.Clone(original)
sortAutomaticCandidates(items, preference)
require.Equal(t, want, items[0].group)
if preference != "cost" {
require.Equal(t, "unknown", items[len(items)-1].group)
}
})
}
}
func TestAutomaticGroupUsesAllAuthorizedModelGroups(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
for _, fixture := range []struct {
id int
group, name string
}{{6921, "default", "smart-model"}, {6922, "vip", "smart-model"}, {6923, "secret", "smart-model"}, {6924, "default", "another-model"}} {
createChannelSelectAutoGroupsChannel(t, db, fixture.id, fixture.group, fixture.name)
}
model.InitChannelCache()
groupModelCostSnapshot.Lock()
oldCosts, oldTime := groupModelCostSnapshot.values, groupModelCostSnapshot.updated
cheap, expensive := 0.1, 0.3
groupModelCostSnapshot.values = map[string]map[string]model.GroupModelCost{"default": {"smart-model": {USDPerMillion: &expensive}}, "vip": {"smart-model": {USDPerMillion: &cheap}}}
groupModelCostSnapshot.updated = time.Now()
groupModelCostSnapshot.Unlock()
automaticMetricCache.Set("smart-model", perfmetrics.QueryResult{})
t.Cleanup(func() {
groupModelCostSnapshot.Lock()
groupModelCostSnapshot.values, groupModelCostSnapshot.updated = oldCosts, oldTime
groupModelCostSnapshot.Unlock()
automaticMetricCache.Purge()
})
makeCtx := func() *gin.Context {
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart", AutoPreference: "cost"})
return c
}
result := ResolveRequestPool(makeCtx(), "smart-model")
require.Equal(t, []model.TokenGroupEntry{{Group: "default", Priority: 0}, {Group: "vip", Priority: 1}}, result)
list := ResolveRequestPool(makeCtx(), "")
require.Len(t, list, 2)
for _, entry := range list {
require.NotEqual(t, "secret", entry.Group)
}
require.Empty(t, ResolveRequestPool(makeCtx(), "unknown-model"))
}
func TestPoolFailoverRespectsRetrySafetyBoundaries(t *testing.T) {
for _, code := range []int{400, 401, 429, 500, 502, 503, 504, 524} {
t.Run(fmt.Sprint(code), func(t *testing.T) {
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart"})
err := apiTypes.NewErrorWithStatusCode(errors.New("test upstream"), apiTypes.ErrorCodeBadResponseStatusCode, code)
require.Equal(t, code == 500 || code == 502 || code == 503, IsPoolFailoverError(c, err))
ctx, cancel := context.WithCancel(c.Request.Context())
cancel()
c.Request = c.Request.WithContext(ctx)
require.False(t, IsPoolFailoverError(c, err))
})
}
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart"})
_, err := c.Writer.WriteString("already streaming")
require.NoError(t, err)
failure := apiTypes.NewErrorWithStatusCode(errors.New("test upstream"), apiTypes.ErrorCodeBadResponseStatusCode, 500)
require.False(t, IsPoolFailoverError(c, failure))
require.Equal(t, "stop", DecideRelayRetry(c, failure, 5).Action)
}
func TestTokenRoutingPoolPreferenceExclusionAndAffinityIsolation(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "pool-model"