feat: sync deployed v1.0.0-rc.38-custom.67
This commit is contained in:
1 parent
d09216bbde
commit
8e6ed0317b
154 files changed
+39540
-20509
No files matched your search
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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"}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type groupAvailabilityState struct {
|
||||
}
|
||||
|
||||
var (
|
||||
groupAvailabilityMu sync.RWMutex
|
||||
groupAvailabilityMu sync.RWMutex
|
||||
groupAvailabilityStates = make(map[string]*groupAvailabilityState)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user