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

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ import (
func UnbindAccountOAuth(identity AuthIdentity, providerID int) error {
enabled := model.AccountLoginMethods{
Password: common.PasswordLoginEnabled,
Passkey: system_setting.GetPasskeySettings().Enabled,
Passkey: system_setting.PasskeySettingsSnapshot().Enabled,
WeChat: common.WeChatAuthEnabled,
}
for _, provider := range oauth.GetAllProviders() {
+322
View File
@@ -0,0 +1,322 @@
package service
import (
"context"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
)
const AstraSamplingTaskType = "astra_fingerprint"
const (
AstraSamplingWindow = 6 * time.Hour
AstraSamplingDefaultInterval = 30 * time.Minute
AstraSamplingUnstableInterval = 30 * time.Minute
AstraAvailabilityInterval = 15 * time.Minute
)
type AstraSamplingRound struct {
ID string `json:"id"`
ChannelID int `json:"channel_id"`
StartedAt int64 `json:"started_at"`
FinishedAt int64 `json:"finished_at,omitempty"`
Sent int `json:"sent"`
Version string `json:"version"`
Result fingerprint.Result `json:"result"`
Reason string `json:"reason,omitempty"`
Usage []fingerprint.ProbeResponse `json:"usage,omitempty"`
}
type AstraSamplingState struct {
Rounds []AstraSamplingRound `json:"rounds"`
}
type ChannelAvailabilityRound struct {
ChannelID int `json:"channel_id"`
At int64 `json:"at"`
Success bool `json:"success"`
}
type ChannelAvailabilityState struct {
Rounds []ChannelAvailabilityRound `json:"rounds"`
}
type AstraSamplingHistory struct {
UserInitiated bool `json:"user_initiated,omitempty"`
ID string `json:"id"`
At int64 `json:"at"`
Status string `json:"status"`
ValidSamples int `json:"valid_samples"`
Closest string `json:"closest,omitempty"`
Gap float64 `json:"score_gap,omitempty"`
}
type AstraSamplingSummary struct {
Enabled bool `json:"enabled"`
Available bool `json:"available"`
Channels int `json:"channels"`
SampledChannels int `json:"sampled_channels"`
Matches int `json:"matches"`
Mismatches int `json:"mismatches"`
Inconclusive int `json:"inconclusive"`
Errors int `json:"errors"`
Running int `json:"running"`
Rate *float64 `json:"rate"`
WindowHours int `json:"window_hours"`
IntervalMinutes int `json:"interval_minutes"`
IntervalMaxMinutes int `json:"interval_max_minutes"`
AvailabilityOnly bool `json:"availability_only,omitempty"`
AvailabilityIntervalMinutes int `json:"availability_interval_minutes,omitempty"`
AvailabilityChecks int `json:"availability_checks,omitempty"`
AvailabilitySuccesses int `json:"availability_successes,omitempty"`
AvailabilityRate *float64 `json:"availability_rate,omitempty"`
History []AstraSamplingHistory `json:"history"`
}
func AstraSamplingEnabled() bool {
return common.GetEnvOrDefaultBool("ASTRA_FINGERPRINT_ENABLED", false)
}
func AstraAvailabilityEnabled() bool {
return common.GetEnvOrDefaultBool("CHANNEL_AVAILABILITY_PROBE_ENABLED", true)
}
func ChannelSupportsAstra(channel *model.Channel) bool {
if channel == nil {
return false
}
for _, name := range channel.GetModels() {
if strings.TrimSpace(name) == fingerprint.Model {
return true
}
}
return false
}
func AstraSamplingIntervalForGroup(group string) time.Duration {
return AstraSamplingDefaultInterval
}
func AstraSamplingIntervalForChannel(channel model.Channel) time.Duration {
interval := AstraSamplingDefaultInterval
for _, group := range channel.GetGroups() {
interval = min(interval, AstraSamplingIntervalForGroup(group))
}
return interval
}
func AstraSamplingSchedulerInterval() time.Duration {
return 15 * time.Second
}
func AstraAvailabilityIntervalMinutes() int {
minutes := common.GetEnvOrDefault("CHANNEL_AVAILABILITY_INTERVAL_MINUTES", int(AstraAvailabilityInterval/time.Minute))
if minutes <= 0 {
return int(AstraAvailabilityInterval / time.Minute)
}
return minutes
}
// 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 {
closest := strings.TrimSpace(result.Closest)
if result.Status != "running" && result.ValidSamples > 0 && closest != "" {
if strings.EqualFold(closest, fingerprint.Model) {
return "match"
}
return "mismatch"
}
if result.Status == "match" || result.Status == "mismatch" {
return "inconclusive"
}
return result.Status
}
// No credentials are fetched by the public status query. Each eligible channel
// is sampled once even when it belongs to multiple groups.
func AstraSamplingChannels(ctx context.Context) ([]model.Channel, error) {
return samplingChannels(ctx, true)
}
func samplingChannels(ctx context.Context, requireAstra bool) ([]model.Channel, error) {
var rows []model.Channel
err := model.DB.WithContext(ctx).Select("id", "type", "group", "models", "status").Where("status = ?", common.ChannelStatusEnabled).Find(&rows).Error
if err != nil {
return nil, err
}
channels := make([]model.Channel, 0, len(rows))
for _, channel := range rows {
if !requireAstra {
channels = append(channels, channel)
continue
}
if ChannelSupportsAstra(&channel) {
channels = append(channels, channel)
}
}
return channels, nil
}
func AstraSamplingChannelsDue(ctx context.Context, now time.Time) ([]model.Channel, error) {
due, _, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", now)
return due, err
}
func AstraSamplingSummaries(ctx context.Context, now time.Time, settings map[string]model.StatusPageGroup) (map[string]*AstraSamplingSummary, error) {
channels, err := samplingChannels(ctx, false)
if err != nil {
return nil, err
}
var tasks []*model.SystemTask
err = model.DB.WithContext(ctx).Select("status", "state").Where("type = ? AND created_at >= ?", AstraSamplingTaskType, now.Add(-AstraSamplingWindow).Unix()).Order("id asc").Find(&tasks).Error
if err != nil {
return nil, err
}
var availabilityTasks []*model.SystemTask
err = model.DB.WithContext(ctx).Select("status", "state").Where("type = ? AND created_at >= ?", model.SystemTaskTypeChannelAvailability, now.Add(-AstraSamplingWindow).Unix()).Order("id asc").Find(&availabilityTasks).Error
if err != nil {
return nil, err
}
summaries := aggregateAstraSampling(channels, tasks, now, settings)
AggregateChannelAvailability(summaries, channels, availabilityTasks, now)
ranges, err := DetectionScheduleRanges(ctx, channels, "fingerprint")
if err != nil {
return nil, err
}
for group, summary := range summaries {
if r, ok := ranges[group]; ok {
summary.IntervalMinutes, summary.IntervalMaxMinutes = r[0], r[1]
} else {
summary.IntervalMaxMinutes = summary.IntervalMinutes
}
}
return summaries, nil
}
func AggregateAstraSampling(channels []model.Channel, tasks []*model.SystemTask, now time.Time) map[string]*AstraSamplingSummary {
return aggregateAstraSampling(channels, tasks, now, nil)
}
func aggregateAstraSampling(channels []model.Channel, tasks []*model.SystemTask, now time.Time, settings map[string]model.StatusPageGroup) map[string]*AstraSamplingSummary {
groups := map[string]*AstraSamplingSummary{}
channelGroups := map[int][]string{}
seen := map[string]map[int]bool{}
for _, channel := range channels {
channelGroups[channel.Id] = channel.GetGroups()
astra := ChannelSupportsAstra(&channel)
for _, group := range channel.GetGroups() {
if groups[group] == nil {
groups[group] = &AstraSamplingSummary{Enabled: AstraSamplingEnabled(), Available: true, WindowHours: int(AstraSamplingWindow / time.Hour), IntervalMinutes: int(AstraSamplingIntervalForGroup(group) / time.Minute), History: []AstraSamplingHistory{}}
seen[group] = map[int]bool{}
}
if astra {
groups[group].Channels++
}
}
}
for _, task := range tasks {
var state AstraSamplingState
if task.DecodeState(&state) != nil {
continue
}
for _, round := range state.Rounds {
if round.StartedAt < now.Add(-AstraSamplingWindow).Unix() {
continue
}
if !containsAstraChannel(channels, round.ChannelID) {
continue
}
status := AstraSamplingResultStatus(round.Result)
if status == "running" && (task.Status == model.SystemTaskStatusFailed || task.Status == model.SystemTaskStatusSucceeded) {
status = "error"
}
for _, group := range channelGroups[round.ChannelID] {
if cutoff := settings[group].ResetBefore; cutoff > 0 && round.StartedAt <= cutoff {
continue
}
s := groups[group]
switch status {
case "match":
s.Matches++
case "mismatch":
s.Mismatches++
case "inconclusive":
s.Inconclusive++
case "error":
s.Errors++
case "running":
s.Running++
default:
continue
}
if round.FinishedAt > 0 {
seen[group][round.ChannelID] = true
}
s.History = append(s.History, AstraSamplingHistory{ID: round.ID, At: round.StartedAt, Status: status, ValidSamples: round.Result.ValidSamples, Closest: round.Result.Closest, Gap: round.Result.Gap})
}
}
}
for group, s := range groups {
if s.Channels == 0 {
s.AvailabilityOnly = true
s.IntervalMinutes = AstraAvailabilityIntervalMinutes()
s.AvailabilityIntervalMinutes = AstraAvailabilityIntervalMinutes()
}
s.SampledChannels = len(seen[group])
if n := s.Matches + s.Mismatches; n > 0 {
rate := float64(s.Mismatches) / float64(n)
s.Rate = &rate
}
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]
}
}
return groups
}
func containsAstraChannel(channels []model.Channel, id int) bool {
for i := range channels {
if channels[i].Id == id {
return ChannelSupportsAstra(&channels[i])
}
}
return false
}
func AggregateChannelAvailability(summaries map[string]*AstraSamplingSummary, channels []model.Channel, tasks []*model.SystemTask, now time.Time) {
channelGroups := map[int][]string{}
for i := range channels {
if !ChannelSupportsAstra(&channels[i]) {
channelGroups[channels[i].Id] = channels[i].GetGroups()
}
}
for _, task := range tasks {
var state ChannelAvailabilityState
if task.DecodeState(&state) != nil {
continue
}
for _, round := range state.Rounds {
if round.At < now.Add(-AstraSamplingWindow).Unix() {
continue
}
for _, group := range channelGroups[round.ChannelID] {
s := summaries[group]
s.AvailabilityChecks++
if round.Success {
s.AvailabilitySuccesses++
}
}
}
}
for _, s := range summaries {
if s.AvailabilityChecks > 0 {
rate := float64(s.AvailabilitySuccesses) / float64(s.AvailabilityChecks)
s.AvailabilityRate = &rate
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"context"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type anchoredSamplingTestHandler struct{ stubScheduledHandler }
func (*anchoredSamplingTestHandler) ScheduleFromCreation() bool { return true }
func TestAstraSamplingThirtyMinuteSchedulePersistsAcrossRestart(t *testing.T) {
truncate(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}}}})
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)
}
runSystemTaskScheduler()
if countSystemTasks(t, AstraSamplingTaskType) != 2 {
t.Fatal("schedule drifted from completion instead of creation")
}
runSystemTaskScheduler()
if countSystemTasks(t, AstraSamplingTaskType) != 2 {
t.Fatal("restart duplicated pending job")
}
channels, err := AstraSamplingChannels(context.Background())
if err != nil {
t.Fatal(err)
}
_ = channels
loaded, err := model.GetSystemTaskByTaskID(row.TaskID)
if err != nil {
t.Fatal(err)
}
summary := AggregateAstraSampling([]model.Channel{{Id: 42, Group: "group", Models: fingerprint.Model}}, []*model.SystemTask{loaded}, time.Now())
if summary["group"].Matches != 1 {
t.Fatal("completed results did not survive reload")
}
}
func TestAstraSamplingAggregationPrivacyAndFailures(t *testing.T) {
now := time.Now()
channels := []model.Channel{{Id: 1, Group: "first,shared", Models: fingerprint.Model}, {Id: 2, Group: "first", Models: fingerprint.Model}, {Id: 3, Group: "empty", Models: fingerprint.Model}}
rounds := []AstraSamplingRound{}
for _, status := range []string{"match", "mismatch", "inconclusive", "error", "running"} {
closest := ""
if status == "match" {
closest = fingerprint.Model
}
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: 99, StartedAt: now.Unix(), Result: fingerprint.Result{Status: "mismatch"}})
state, _ := common.Marshal(AstraSamplingState{Rounds: rounds})
summaries := AggregateAstraSampling(channels, []*model.SystemTask{{Status: model.SystemTaskStatusFailed, State: string(state)}}, now)
first := summaries["first"]
if first.Rate == nil || *first.Rate != .5 || first.Errors != 2 || first.Inconclusive != 1 || first.Running != 0 || first.SampledChannels != 1 || first.Channels != 2 {
t.Fatalf("%+v", first)
}
if *summaries["shared"].Rate != .5 || summaries["empty"].Rate != nil {
t.Fatal("shared results / empty denominator")
}
public, _ := common.Marshal(summaries)
var raw map[string]any
if common.Unmarshal(public, &raw) != nil {
t.Fatal("invalid public JSON")
}
for _, h := range first.History {
if h.Status == "running" {
t.Fatal("restart must fail unfinished sample")
}
}
if len(first.History) != 5 {
t.Fatal("deleted/unrelated channel leaked into current group")
}
}
func TestAstraSamplingAggregationCountsLunaLikeInconclusiveWithinSixHours(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}},
}
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 {
t.Fatalf("%+v", summary)
}
if summary.WindowHours != 6 || summary.IntervalMinutes != 30 || len(summary.History) != 2 {
t.Fatalf("unexpected window, interval or history: %+v", summary)
}
}
func TestAstraSamplingAggregationMarksNonAstraGroupsAvailabilityOnly(t *testing.T) {
now := time.Now()
channels := []model.Channel{{Id: 1, Group: "opencode", Models: "gpt-5.6-sol"}}
summary := AggregateAstraSampling(channels, nil, now)["opencode"]
if !summary.AvailabilityOnly || summary.Channels != 0 || summary.IntervalMinutes != 30 || summary.Rate != nil {
t.Fatalf("%+v", summary)
}
state, _ := common.Marshal(ChannelAvailabilityState{Rounds: []ChannelAvailabilityRound{
{ChannelID: 1, At: now.Add(-time.Hour).Unix(), Success: true},
{ChannelID: 1, At: now.Add(-2 * time.Hour).Unix(), Success: false},
{ChannelID: 1, At: now.Add(-7 * time.Hour).Unix(), Success: false},
}})
AggregateChannelAvailability(map[string]*AstraSamplingSummary{"opencode": summary}, channels, []*model.SystemTask{{State: string(state)}}, now)
if summary.AvailabilityRate == nil || *summary.AvailabilityRate != .5 || summary.AvailabilityChecks != 2 || summary.AvailabilitySuccesses != 1 {
t.Fatalf("%+v", summary)
}
}
func TestAstraSamplingCountsAllValidNonAstraFingerprints(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},
}
var rounds []AstraSamplingRound
for _, result := range results {
rounds = append(rounds, AstraSamplingRound{ChannelID: 1, StartedAt: now.Unix(), FinishedAt: now.Unix(), Result: result})
}
state, err := common.Marshal(AstraSamplingState{Rounds: rounds})
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, "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"}))
}
+24 -17
View File
@@ -1,8 +1,9 @@
package authz
import (
"strings"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
casbinmodel "github.com/casbin/casbin/v2/model"
"github.com/casbin/casbin/v2/persist"
@@ -24,7 +25,28 @@ func (a *gormAdapter) LoadPolicy(m casbinmodel.Model) error {
return err
}
for _, rule := range rules {
if err := persist.LoadPolicyLine(ruleToLine(rule), m); err != nil {
if rule.Ptype != "p" {
// This model has no role-inheritance or other policy sections.
continue
}
if rule.V0 == "" || rule.V1 == "" || rule.V2 == "" {
return fmt.Errorf("authorization policy %d is missing a subject, resource or action", rule.Id)
}
effect := rule.V3
if effect == "" {
effect = EffectAllow
}
if effect != EffectAllow && effect != EffectDeny {
return fmt.Errorf("authorization policy %d has an invalid effect", rule.Id)
}
if rule.V5 != "" || (rule.V4 != "" && rule.V4 != "all") {
// The current model cannot enforce legacy own/other scopes. Keep
// the stored rule for review and deny this permission in memory.
// Skipping a per-user rule would expose the admin role baseline.
effect = EffectDeny
common.SysLog(fmt.Sprintf("authorization policy %d has an unsupported legacy scope; loaded as deny; review the stored policy before granting access", rule.Id))
}
if err := persist.LoadPolicyArray([]string{rule.Ptype, rule.V0, rule.V1, rule.V2, effect}, m); err != nil {
return err
}
}
@@ -104,18 +126,3 @@ func newRule(ptype string, policy []string) model.CasbinRule {
}
return rule
}
func ruleToLine(rule model.CasbinRule) string {
parts := []string{rule.Ptype}
values := []string{rule.V0, rule.V1, rule.V2, rule.V3, rule.V4, rule.V5}
if rule.Ptype == "p" && rule.V0 != "" && rule.V1 != "" && rule.V2 != "" && rule.V3 == "" {
values[3] = EffectAllow
}
for _, value := range values {
if value == "" {
continue
}
parts = append(parts, value)
}
return strings.Join(parts, ", ")
}
+49
View File
@@ -77,6 +77,55 @@ func TestInitOnSlaveOnlyLoadsPolicies(t *testing.T) {
assert.False(t, Can(2, common.RoleAdminUser, ChannelRead))
}
func TestLegacyScopedPoliciesDoNotExpandPermissions(t *testing.T) {
for _, master := range []bool{true, false} {
name := "master"
if !master {
name = "slave"
}
t.Run(name, func(t *testing.T) {
db := newAuthzTestDB(t)
common.IsMasterNode = master
rules := []model.CasbinRule{
{Ptype: "p", V0: "role:vendor", V1: "channel", V2: "read", V3: "allow", V4: "own"},
{Ptype: "p", V0: UserSubject(42), V1: "channel", V2: "read", V3: "allow", V4: "own"},
{Ptype: "p", V0: UserSubject(43), V1: "channel", V2: "sensitive_write", V3: "allow", V4: "all"},
{Ptype: "p", V0: UserSubject(44), V1: "channel", V2: "secret_view", V3: "deny", V4: "all"},
{Ptype: "p", V0: UserSubject(45), V1: "channel", V2: "read"},
{Ptype: "p", V0: UserSubject(46), V1: "channel", V2: "read", V3: "allow", V4: "unknown-scope"},
{Ptype: "p", V0: UserSubject(47), V1: "channel", V2: "read", V3: "allow", V5: "own"},
{Ptype: "p", V0: UserSubject(48), V1: "channel", V2: "read", V3: "allow", V4: "own"},
{Ptype: "p", V0: UserSubject(48), V1: "channel", V2: "read", V3: "allow", V4: "all"},
{Ptype: "p", V0: RoleSubject(BuiltInRoleAdmin), V1: "channel", V2: "operate", V3: "allow", V4: "own"},
{Ptype: "p", V0: UserSubject(50), V1: "channel", V2: "sensitive_write", V3: "allow"},
{Ptype: "g", V0: UserSubject(99), V1: RoleSubject(BuiltInRoleAdmin)},
}
require.NoError(t, db.Create(&rules).Error)
ids := make([]uint, len(rules))
for i := range rules {
ids[i] = rules[i].Id
}
for range 2 {
require.NoError(t, Init(db))
require.NoError(t, ReloadPolicy())
assert.False(t, Can(42, common.RoleAdminUser, ChannelRead), "own must not fall back to the admin allow baseline")
assert.True(t, Can(43, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(44, common.RoleAdminUser, ChannelSecretView))
assert.True(t, Can(45, common.RoleAdminUser, ChannelRead))
for _, userID := range []int{46, 47, 48} {
assert.False(t, Can(userID, common.RoleAdminUser, ChannelRead))
}
assert.False(t, Can(51, common.RoleAdminUser, ChannelOperate), "reseed must not erase a scoped role restriction")
assert.True(t, Can(50, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(99, common.RoleCommonUser, ChannelRead))
var stored []model.CasbinRule
require.NoError(t, db.Where("id IN ?", ids).Order("id").Find(&stored).Error)
assert.Equal(t, rules, stored, "legacy rows must remain available for administrator review")
}
})
}
}
func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
+5 -1
View File
@@ -39,7 +39,11 @@ func resetBuiltInRolePolicies(db *gorm.DB) error {
for _, spec := range builtInRoles {
subjects = append(subjects, RoleSubject(spec.Key))
}
return db.Where("ptype = ? AND v0 IN ?", "p", subjects).Delete(&model.CasbinRule{}).Error
// Scoped legacy rules must survive baseline reseeding so the adapter can
// retain their restrictions instead of replacing them with global grants.
return db.Where("ptype = ? AND v0 IN ?", "p", subjects).
Where("(v4 = ? OR v4 IS NULL) AND (v5 = ? OR v5 IS NULL)", "", "").
Delete(&model.CasbinRule{}).Error
}
func seedDefaultPolicies() error {
+23 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/bytedance/gopkg/util/gopool"
@@ -154,7 +155,12 @@ func (s *BillingSession) Reserve(targetQuota int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.settled || s.refunded || s.trusted || targetQuota <= s.preConsumedQuota {
imageRequest := false
if s.relayInfo != nil {
_, imageRequest = s.relayInfo.Request.(*dto.ImageRequest)
imageRequest = imageRequest || s.relayInfo.ImageRequestCount > 0
}
if s.settled || s.refunded || s.trusted && !imageRequest || targetQuota <= s.preConsumedQuota {
return nil
}
@@ -163,7 +169,7 @@ func (s *BillingSession) Reserve(targetQuota int) error {
return nil
}
if err := s.reserveFunding(delta); err != nil {
if err := s.reserveFunding(delta, imageRequest); err != nil {
return err
}
if err := s.reserveToken(delta); err != nil {
@@ -174,6 +180,9 @@ func (s *BillingSession) Reserve(targetQuota int) error {
s.preConsumedQuota += delta
s.tokenConsumed += delta
s.extraReserved += delta
if imageRequest {
s.trusted = false
}
s.syncRelayInfo()
return nil
}
@@ -240,9 +249,20 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
return nil
}
func (s *BillingSession) reserveFunding(delta int) error {
func (s *BillingSession) reserveFunding(delta int, requireAvailableQuota bool) error {
switch funding := s.funding.(type) {
case *WalletFunding:
if requireAvailableQuota {
// Image quantity is known before submission, including retries and
// overrides. Reserve atomically instead of admitting wallet debt.
if err := funding.PreConsume(delta); err != nil {
if errors.Is(err, ErrInsufficientWalletQuota) {
return types.NewErrorWithStatusCode(err, types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
return nil
}
// 与结算补扣(SettleBilling 正差额 → WalletFunding.Settle)语义一致:
// 全额无条件扣减,余额不足的部分记为欠费(余额可为负),不中断请求,
// 保证日志记录的预扣额度与用户余额的实际变动始终对账一致。
+12
View File
@@ -15,6 +15,15 @@ func formatNotifyType(channelId int, status int) string {
return fmt.Sprintf("%s_%d_%d", dto.NotifyTypeChannelUpdate, channelId, status)
}
func shouldCloseActiveWebSocketsAfterDisable(channelId int) bool {
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.SysLog(fmt.Sprintf("failed to check channel status before closing active websockets: channel_id=%d, error=%v", channelId, err))
return true
}
return channel.Status != common.ChannelStatusEnabled
}
// disable & notify
func DisableChannel(channelError types.ChannelError, reason string) {
common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, common.LocalLogPreview(reason)))
@@ -27,6 +36,9 @@ func DisableChannel(channelError types.ChannelError, reason string) {
success := model.UpdateChannelStatus(channelError.ChannelId, channelError.UsingKey, common.ChannelStatusAutoDisabled, reason)
if success {
if shouldCloseActiveWebSocketsAfterDisable(channelError.ChannelId) {
CloseActiveWebSocketsForChannel(channelError.ChannelId, ChannelDisabledCloseReason)
}
subject := fmt.Sprintf("通道「%s」(#%d)已被禁用", channelError.ChannelName, channelError.ChannelId)
content := fmt.Sprintf("通道「%s」(#%d)已被禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason)
NotifyRootUser(formatNotifyType(channelError.ChannelId, common.ChannelStatusAutoDisabled), subject, content)
+18 -1
View File
@@ -550,6 +550,7 @@ func ApplyChannelAffinityOverrideTemplate(c *gin.Context, paramOverride map[stri
func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
setting := operation_setting.GetChannelAffinitySetting()
state := RequestPolicy(c)
if setting == nil || !setting.Enabled {
return 0, false
}
@@ -594,11 +595,13 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
}
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
state.SessionMode, state.SessionModeSource = EffectiveSessionMode(setting, rule)
state.RuleName = rule.Name
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
TTLSeconds: ttlSeconds,
RuleName: rule.Name,
SkipRetry: rule.SkipRetryOnFailure,
SkipRetry: state.SessionMode == "strict",
ParamTemplate: cloneStringAnyMap(rule.ParamOverrideTemplate),
KeySourceType: strings.TrimSpace(usedSource.Type),
KeySourceKey: strings.TrimSpace(usedSource.Key),
@@ -610,6 +613,10 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
RequestPath: path,
})
state.AddEvent(PolicyEvent{Decision: PolicyDecision{Action: "match", Reason: "session_rule_matched", Source: "session_rule"}})
if state.SessionMode == "off" {
return 0, false
}
cache := getChannelAffinityCache()
channelID, found, err := cache.Get(cacheKeySuffix)
if err != nil {
@@ -719,6 +726,13 @@ func AppendChannelAffinityAdminInfo(c *gin.Context, other *model.LogOther) {
if !ok || anyInfo == nil {
return
}
// The hint can be the whole session value for short keys; logs keep only
// the fingerprint.
if info, ok := anyInfo.(map[string]any); ok {
sanitized := cloneStringAnyMap(info)
delete(sanitized, "key_hint")
anyInfo = sanitized
}
other.SetAdmin("channel_affinity", anyInfo)
}
@@ -727,6 +741,9 @@ func RecordChannelAffinity(c *gin.Context, channelID int) {
return
}
setting := operation_setting.GetChannelAffinitySetting()
if RequestPolicy(c).SessionMode == "off" {
return
}
if setting == nil || !setting.Enabled {
return
}
+133
View File
@@ -1,6 +1,7 @@
package service
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -8,9 +9,14 @@ import (
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -332,3 +338,130 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
_, exists = info.RuntimeHeadersOverride["x-codex-turn-metadata"]
require.False(t, exists)
}
func TestMidjourneyPolicyAcceptance(t *testing.T) {
for _, tc := range []struct {
name string
status int
code int
properties map[string]any
requestErr error
accepted bool
reason string
}{
{name: "submitted", status: 200, code: 1, accepted: true},
{name: "existing task", status: 200, code: 21, accepted: true},
{name: "queued", status: 200, code: 22, accepted: true},
{name: "HTTP 200 with rejected prompt", status: 200, code: 24, reason: "non_retryable_error"},
{name: "failed existing task", status: 200, code: 21, properties: map[string]any{"status": "FAILURE"}, reason: "task_accepted"},
{name: "HTTP failure", status: 502, code: 1, reason: "non_retryable_error"},
{name: "ambiguous transport failure", status: 502, requestErr: errors.New("connection reset"), reason: "non_retryable_error"},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/mj/submit/imagine", strings.NewReader(`{}`))
ctx.Set("channel_id", 7)
state := RequestPolicy(ctx)
state.BeginAttempt(&model.Channel{Id: 7}, "default")
response := &dto.MidjourneyResponseWithStatusCode{StatusCode: tc.status, Response: dto.MidjourneyResponse{Code: tc.code, Properties: tc.properties}}
accepted := RecordMidjourneyPolicyResponse(ctx, response, tc.requestErr)
assert.Equal(t, tc.accepted, accepted)
assert.False(t, state.Successful, "acceptance alone must not bind before local processing finishes")
if accepted {
MarkRequestPolicySuccess(ctx, nil)
assert.True(t, state.Successful)
return
}
events := state.Events()
require.Len(t, events, 3)
assert.Equal(t, PolicyDecision{Action: "failure", Reason: "upstream_failure", Source: "upstream"}, events[1].Decision)
assert.Equal(t, tc.status, events[1].Status)
assert.Equal(t, PolicyDecision{Action: "stop", Reason: tc.reason, Source: "system"}, events[2].Decision, "submissions are never replayed")
})
}
}
func TestSessionRulesInheritOrOverrideGlobalDefault(t *testing.T) {
setting := operation_setting.GetChannelAffinitySetting()
previous := *setting
t.Cleanup(func() { *setting = previous })
for _, globalMode := range []string{"", "off", "prefer", "strict"} {
for _, tc := range []struct {
name, mode, expected string
skipRetry bool
}{
{name: "inherit", mode: "inherit", expected: globalMode},
{name: "override-strict", mode: "strict", expected: "strict"},
{name: "override-prefer", mode: "prefer", expected: "prefer", skipRetry: true},
{name: "override-off", mode: "off", expected: "off", skipRetry: true},
{name: "legacy-strict", expected: "strict", skipRetry: true},
{name: "legacy-prefer", expected: "prefer"},
} {
t.Run(globalMode+"/"+tc.name, func(t *testing.T) {
expected := tc.expected
if expected == "" {
expected = "prefer"
}
source := "session_rule"
if tc.mode == "inherit" {
source = "global"
}
rule := operation_setting.ChannelAffinityRule{
Name: tc.name, ModelRegex: []string{"^test-model$"},
KeySources: []operation_setting.ChannelAffinityKeySource{{Type: "request_header", Key: "X-Session"}},
SessionMode: tc.mode, SkipRetryOnFailure: tc.skipRetry,
ParamOverrideTemplate: map[string]any{"temperature": 0}, IncludeRuleName: true,
}
encoded, err := common.Marshal([]operation_setting.ChannelAffinityRule{rule})
require.NoError(t, err)
snapshot, err := model.BuildRequestPolicy(map[string]string{"RetryTimes": "2", "channel_affinity_setting.enabled": "true", "channel_affinity_setting.session_mode": globalMode, "channel_affinity_setting.rules": string(encoded)})
require.NoError(t, err)
*setting = snapshot.Affinity
key := t.Name()
cacheKey := buildChannelAffinityCacheKeySuffix(rule, "test-model", "default", key)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKey, 1, time.Minute))
t.Cleanup(func() { _, err := cache.DeleteMany([]string{cacheKey}); assert.NoError(t, err) })
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
ctx.Request.Header.Set("X-Session", key)
state := RequestPolicy(ctx)
channelID, found := GetPreferredChannelByAffinity(ctx, "test-model", "default")
assert.Equal(t, expected != "off", found)
if found {
assert.Equal(t, 1, channelID)
}
assert.Equal(t, expected, state.SessionMode)
assert.Equal(t, source, state.SessionModeSource)
assert.Equal(t, expected == "strict", ShouldSkipRetryAfterChannelAffinityFailure(ctx))
decision := DecideRelayRetry(ctx, types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusTooManyRequests), 2)
if expected == "strict" {
assert.Equal(t, PolicyDecision{Action: "stop", Reason: "strict_session", Source: source}, decision)
} else {
assert.Equal(t, "retry", decision.Action)
}
events := state.Events()
require.Len(t, events, 1)
assert.Equal(t, PolicyDecision{Action: "match", Reason: "session_rule_matched", Source: "session_rule"}, events[0].Decision)
assert.Equal(t, tc.name, events[0].Rule)
transformed, applied := ApplyChannelAffinityOverrideTemplate(ctx, nil)
assert.True(t, applied, "session behavior does not disable request transforms")
assert.EqualValues(t, 0, transformed["temperature"])
if expected == "off" {
ctx.Set("channel_id", 9)
RecordChannelAffinity(ctx, 9)
cached, found, err := cache.Get(cacheKey)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, 1, cached, "off does not refresh session bindings")
_, err = cache.DeleteMany([]string{cacheKey})
require.NoError(t, err)
RecordChannelAffinity(ctx, 9)
_, found, err = cache.Get(cacheKey)
require.NoError(t, err)
assert.False(t, found, "off does not establish session bindings")
}
})
}
}
}
+344 -26
View File
@@ -2,13 +2,21 @@ package service
import (
"errors"
"fmt"
"net/http"
"sort"
"sync"
"sync/atomic"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
)
@@ -28,10 +36,12 @@ func AppendTaskPluginIdentityFilter(c *gin.Context, pluginKey string) {
if c == nil {
return
}
channelTypes, pluginKeys := pinnedTaskPluginIdentities(c, pluginKey)
GetChannelConstraints(c).AddFilter(dto.ChannelFilter{
Kind: dto.FilterTaskPluginIdentity,
TaskPluginKey: pluginKey,
TaskPluginChannelTypes: pinnedTaskPluginChannelTypes(c, pluginKey),
TaskPluginChannelTypes: channelTypes,
TaskPluginKeys: pluginKeys,
})
}
@@ -70,6 +80,159 @@ func (p *RetryParam) ResetRetryNextTry() {
p.resetNextTry = true
}
// groupPoolTier is a set of groups sharing one priority. Tiers are tried in
// ascending priority order, and the groups inside a tier are load-balanced.
type groupPoolTier struct {
priority int
groups []string
}
var groupPoolRoundRobinCounters sync.Map
// buildGroupPoolTiers resolves the token's routing pool into ordered tiers.
// Entries the user group cannot select are dropped, and the token's own group is
// placed ahead of the configured pool when it is missing from it.
func buildGroupPoolTiers(c *gin.Context, primary, userGroup string) []groupPoolTier {
value, exists := common.GetContextKey(c, constant.ContextKeyTokenGroupPool)
if !exists {
return nil
}
entries, ok := value.([]model.TokenGroupEntry)
if !ok || len(entries) == 0 {
return nil
}
maxCount := setting.GetMaxTokenAutoGroups()
ordered := make([]model.TokenGroupEntry, 0, len(entries)+1)
seen := make(map[string]struct{}, len(entries)+1)
hasPrimary := false
for _, entry := range entries {
if len(ordered) >= maxCount {
break
}
if !IsUserSelectableGroup(userGroup, entry.Group) {
continue
}
if _, duplicate := seen[entry.Group]; duplicate {
continue
}
seen[entry.Group] = struct{}{}
if entry.Group == primary {
hasPrimary = true
}
ordered = append(ordered, entry)
}
if len(ordered) == 0 {
return nil
}
if !hasPrimary && primary != "" && primary != "auto" {
minPriority := ordered[0].Priority
for _, entry := range ordered {
if entry.Priority < minPriority {
minPriority = entry.Priority
}
}
ordered = append([]model.TokenGroupEntry{{Group: primary, Priority: minPriority - 1}}, ordered...)
}
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Priority < ordered[j].Priority })
tiers := make([]groupPoolTier, 0, len(ordered))
for _, entry := range ordered {
if len(tiers) > 0 && tiers[len(tiers)-1].priority == entry.Priority {
tiers[len(tiers)-1].groups = append(tiers[len(tiers)-1].groups, entry.Group)
continue
}
tiers = append(tiers, groupPoolTier{priority: entry.Priority, groups: []string{entry.Group}})
}
return tiers
}
func failedRequestGroups(c *gin.Context) map[string]bool {
failed := make(map[string]bool)
groups, ok := common.GetContextKeyType[[]string](c, constant.ContextKeyTokenFailedGroups)
if !ok {
return failed
}
for _, group := range groups {
failed[group] = true
}
return failed
}
// MarkRequestGroupFailed records that a group already failed during the current
// request so the next selection switches away from it instead of retrying it.
func MarkRequestGroupFailed(c *gin.Context, group string) {
if c == nil || group == "" {
return
}
groups, _ := common.GetContextKeyType[[]string](c, constant.ContextKeyTokenFailedGroups)
for _, existing := range groups {
if existing == group {
return
}
}
common.SetContextKey(c, constant.ContextKeyTokenFailedGroups, append(groups, group))
}
func nextTierIndex(key string, size int) int {
if size <= 1 {
return 0
}
value, _ := groupPoolRoundRobinCounters.LoadOrStore(key, new(uint64))
counter, ok := value.(*uint64)
if !ok {
return 0
}
return int(atomic.AddUint64(counter, 1)-1) % size
}
func selectFromGroupPool(param *RetryParam, tiers []groupPoolTier, filters []dto.ChannelFilter) (*model.Channel, string) {
failed := failedRequestGroups(param.Ctx)
// Each retry advances one priority tier, so a retryable failure reaches the
// next available group instead of repeating the group that just failed.
start := param.GetRetry()
if start < 0 {
start = 0
}
if start > len(tiers)-1 {
start = len(tiers) - 1
}
for _, tier := range tiers[start:] {
eligible := make([]string, 0, len(tier.groups))
for _, group := range tier.groups {
if failed[group] || !IsGroupAvailable(group) {
continue
}
eligible = append(eligible, group)
}
if len(eligible) == 0 {
continue
}
start := nextTierIndex(tier.groups[0], len(eligible))
for offset := 0; offset < len(eligible); offset++ {
group := eligible[(start+offset)%len(eligible)]
if channel, _ := selectWithTrace(param, group, 0); channel != nil {
logger.LogInfo(param.Ctx, fmt.Sprintf("group pool selected: group=%s channel=%d", group, channel.Id))
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, group)
return channel, group
}
}
}
// Availability is a heuristic, so when every tier looks degraded fall back to
// the highest-priority group this request has not tried yet rather than
// failing without an attempt.
for _, tier := range tiers {
for _, group := range tier.groups {
if failed[group] {
continue
}
if channel, _ := selectWithTrace(param, group, 0); channel != nil {
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, group)
return channel, group
}
}
}
return nil, param.TokenGroup
}
// CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements.
// 尝试获取一个满足要求的随机渠道。
//
@@ -106,6 +269,7 @@ func (p *RetryParam) ResetRetryNextTry() {
// 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) }
var channel *model.Channel
var err error
selectGroup := param.TokenGroup
@@ -113,6 +277,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
filters := GetChannelConstraints(param.Ctx).Filters
crossGroupRetry := common.GetContextKeyBool(param.Ctx, constant.ContextKeyTokenCrossGroupRetry)
orderedGroups := make([]string, 0)
tiers := make([]groupPoolTier, 0)
if param.TokenGroup == "auto" {
orderedGroups = GetRequestAutoGroups(param.Ctx, userGroup)
@@ -120,19 +285,31 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
return nil, selectGroup, errors.New("auto groups is not enabled")
}
} else if crossGroupRetry {
if value, exists := common.GetContextKey(param.Ctx, constant.ContextKeyTokenAutoGroups); exists {
if backupGroups, ok := value.([]string); ok {
orderedGroups = append(orderedGroups, param.TokenGroup)
for _, group := range FilterUserTokenAutoGroups(userGroup, backupGroups) {
if group != param.TokenGroup {
orderedGroups = append(orderedGroups, group)
if _, exists := common.GetContextKey(param.Ctx, constant.ContextKeyTokenGroupPool); exists {
tiers = buildGroupPoolTiers(param.Ctx, param.TokenGroup, userGroup)
}
if len(tiers) == 0 {
if value, exists := common.GetContextKey(param.Ctx, constant.ContextKeyTokenAutoGroups); exists {
if backupGroups, ok := value.([]string); ok {
orderedGroups = append(orderedGroups, param.TokenGroup)
for _, group := range FilterUserTokenAutoGroups(userGroup, backupGroups) {
if group != param.TokenGroup {
orderedGroups = append(orderedGroups, group)
}
}
}
}
}
}
if len(tiers) > 0 {
logger.LogInfo(param.Ctx, fmt.Sprintf("group pool selection: tokenGroup=%s tiers=%d retry=%d", param.TokenGroup, len(tiers), param.GetRetry()))
channel, selectGroup = selectFromGroupPool(param, tiers, filters)
return channel, selectGroup, nil
}
if len(orderedGroups) > 0 {
logger.LogInfo(param.Ctx, fmt.Sprintf("legacy group selection: groups=%v retry=%d", orderedGroups, param.GetRetry()))
// startGroupIndex: the group index to start searching from
// startGroupIndex: 开始搜索的分组索引
startGroupIndex := 0
@@ -155,12 +332,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
channel, _ = model.GetRandomSatisfiedChannel(
autoGroup,
param.ModelName,
priorityRetry,
filters,
)
channel, _ = selectWithTrace(param, autoGroup, priorityRetry)
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
@@ -198,12 +370,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
break
}
} else {
channel, err = model.GetRandomSatisfiedChannel(
param.TokenGroup,
param.ModelName,
param.GetRetry(),
filters,
)
channel, err = selectWithTrace(param, param.TokenGroup, param.GetRetry())
if err != nil {
return nil, param.TokenGroup, err
}
@@ -211,15 +378,16 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
return channel, selectGroup, nil
}
func pinnedTaskPluginChannelTypes(c *gin.Context, expected string) []int {
func pinnedTaskPluginIdentities(c *gin.Context, expected string) ([]int, []string) {
if c == nil || expected == "" {
return nil
return nil, nil
}
if value, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint); exists {
pinned, ok := value.(jsplugin.PinnedEndpoint)
if ok && pinned.Generation != nil && len(pinned.Candidates) > 1 {
expectedFound := false
channelTypes := make([]int, 0, len(pinned.Candidates))
pluginKeys := make([]string, 0, len(pinned.Candidates))
seen := make(map[int]struct{}, len(pinned.Candidates))
for _, candidate := range pinned.Candidates {
if candidate.Plugin == nil {
@@ -228,6 +396,7 @@ func pinnedTaskPluginChannelTypes(c *gin.Context, expected string) []int {
if candidate.Plugin.Meta.Key == expected {
expectedFound = true
}
pluginKeys = append(pluginKeys, candidate.Plugin.Meta.Key)
for _, channelType := range candidate.Plugin.Meta.ChannelTypes {
if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
continue
@@ -242,14 +411,14 @@ func pinnedTaskPluginChannelTypes(c *gin.Context, expected string) []int {
}
}
if expectedFound {
return channelTypes
return channelTypes, pluginKeys
}
}
}
value, exists := c.Get(jsplugin.ContextKeyPinnedPlugin)
pinned, ok := value.(jsplugin.PinnedPlugin)
if !exists || !ok || pinned.Generation == nil || pinned.Plugin == nil || pinned.Plugin.Meta.Key != expected {
return nil
return nil, nil
}
channelTypes := make([]int, 0, len(pinned.Plugin.Meta.ChannelTypes))
for _, channelType := range pinned.Plugin.Meta.ChannelTypes {
@@ -258,8 +427,157 @@ func pinnedTaskPluginChannelTypes(c *gin.Context, expected string) []int {
}
channelTypes = append(channelTypes, channelType)
}
if len(channelTypes) == 0 {
return nil
return channelTypes, []string{expected}
}
// ChannelSelectError explains why SelectChannelForRequest found no channel.
// Callers render it for their transport: the HTTP distributor localizes
// MessageID with its own helpers and the Responses WebSocket relay wraps it in
// a NewAPIError. Message is set instead of MessageID when the text is a fixed
// error code that clients match on.
type ChannelSelectError struct {
StatusCode int
Code types.ErrorCode
MessageID string
Params map[string]any
Message string
// FilterKind and Channel identify a candidate rejected by request filters.
FilterKind dto.ChannelFilterKind
Channel *model.Channel
// NoAvailableChannel marks the "no channel for this group and model"
// outcome so the distributor can name the claiming task plugin.
NoAvailableChannel bool
}
// SelectChannelForRequest resolves the channel for one attempt with the rules
// shared by the HTTP distributor and the Responses WebSocket relay: a pinned
// channel wins, then session affinity (first attempt only), then a random
// eligible channel; every candidate must satisfy the request's channel
// filters. The group the channel was chosen from is returned for auto-group
// callers. The caller still applies SetupContextForSelectedChannel.
func SelectChannelForRequest(c *gin.Context, modelName string, retry *RetryParam) (*model.Channel, string, *ChannelSelectError) {
c.Set("original_model", modelName)
constraints := GetChannelConstraints(c)
if pin, found, overridden := constraints.ResolvedPin(); found {
for _, lost := range overridden {
logger.LogWarn(c, fmt.Sprintf(
"channel pin overridden: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d",
pin.Source, pin.ChannelId, lost.Source, lost.ChannelId,
))
}
channel, err := model.CacheGetChannel(pin.ChannelId)
if err != nil {
recordDirectSelection(c, nil, pin.ChannelId, retry.TokenGroup, modelName, "pinned_channel_missing")
return nil, "", pinnedChannelUnavailable(pin, http.StatusBadRequest, i18n.MsgDistributorInvalidChannelId)
}
if channel.Status != common.ChannelStatusEnabled {
recordDirectSelection(c, channel, channel.Id, retry.TokenGroup, modelName, "channel_disabled")
return nil, "", pinnedChannelUnavailable(pin, http.StatusForbidden, i18n.MsgDistributorChannelDisabled)
}
if ok, kind := model.ChannelSatisfiesFilters(channel, modelName, constraints.Filters); !ok {
recordDirectSelection(c, channel, channel.Id, retry.TokenGroup, modelName, "excluded_"+string(kind))
return nil, "", &ChannelSelectError{
StatusCode: http.StatusBadRequest, Code: types.ErrorCode(kind), MessageID: i18n.MsgDistributorNoAvailableChannel,
Params: map[string]any{"Group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), "Model": modelName},
FilterKind: kind, Channel: channel,
}
}
recordDirectSelection(c, channel, channel.Id, retry.TokenGroup, modelName, "pinned_channel")
return channel, "", nil
}
return channelTypes
usingGroup := retry.TokenGroup
if RequestRoutingPolicy(c) != nil {
if channel, group := PreferredPoolChannel(c, modelName); channel != nil && retry.GetRetry() == 0 { 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
}
var channel *model.Channel
var selectGroup string
if retry.GetRetry() == 0 {
if preferredChannelID, found := GetPreferredChannelByAffinity(c, modelName, usingGroup); found {
affinityUsable := false
preferred, err := model.CacheGetChannel(preferredChannelID)
affinitySatisfied := false
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
affinitySatisfied, _ = model.ChannelSatisfiesFilters(preferred, modelName, constraints.Filters)
}
if affinitySatisfied {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
for _, g := range GetRequestAutoGroups(c, userGroup) {
if model.IsChannelEnabledForGroupModel(g, modelName, preferred.Id) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
affinityUsable = true
MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelName, preferred.Id) {
channel = preferred
selectGroup = usingGroup
affinityUsable = true
MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
}
reason := "affinity_unavailable"
if affinityUsable {
reason = "affinity_hit"
}
recordDirectSelection(c, preferred, preferredChannelID, usingGroup, modelName, reason)
if !affinityUsable && !ShouldKeepChannelAffinityOnChannelDisabled() {
ClearCurrentChannelAffinityCache(c)
}
if !affinityUsable && RequestPolicy(c).SessionMode == "strict" {
return nil, "", &ChannelSelectError{StatusCode: http.StatusServiceUnavailable, Message: "strict_session_binding_unavailable"}
}
}
}
if channel == nil {
var err error
channel, selectGroup, err = CacheGetRandomSatisfiedChannel(retry)
if err != nil {
showGroup := usingGroup
if usingGroup == "auto" {
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
}
return nil, selectGroup, &ChannelSelectError{
StatusCode: http.StatusServiceUnavailable, Code: types.ErrorCodeModelNotFound, MessageID: i18n.MsgDistributorGetChannelFailed,
Params: map[string]any{"Group": showGroup, "Model": modelName, "Error": err.Error()},
}
}
if channel == nil {
return nil, selectGroup, &ChannelSelectError{
StatusCode: http.StatusServiceUnavailable, Code: types.ErrorCodeModelNotFound, MessageID: i18n.MsgDistributorNoAvailableChannel,
Params: map[string]any{"Group": usingGroup, "Model": modelName}, NoAvailableChannel: true,
}
}
}
if ok, kind := model.ChannelSatisfiesFilters(channel, modelName, constraints.Filters); !ok {
return nil, selectGroup, &ChannelSelectError{
StatusCode: http.StatusServiceUnavailable, Code: types.ErrorCodeModelNotFound, MessageID: i18n.MsgDistributorNoAvailableChannel,
Params: map[string]any{"Group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), "Model": modelName},
FilterKind: kind, Channel: channel, NoAvailableChannel: true,
}
}
return channel, selectGroup, nil
}
// Origin-task pins report a fixed code so task polling can tell a retired
// channel from a malformed request.
func pinnedChannelUnavailable(pin dto.ChannelPin, statusCode int, messageID string) *ChannelSelectError {
if pin.Source == dto.PinSourceOriginTask {
return &ChannelSelectError{StatusCode: http.StatusBadRequest, Code: "origin_task_channel_disabled", Message: "origin_task_channel_disabled"}
}
return &ChannelSelectError{StatusCode: statusCode, MessageID: messageID}
}
// AppendUsedChannel records an attempted channel in the request's channel
// trail, which the retry log and the consume log's admin_info both read.
func AppendUsedChannel(c *gin.Context, channelID int) {
c.Set("use_channel", append(c.GetStringSlice("use_channel"), fmt.Sprintf("%d", channelID)))
}
+39 -6
View File
@@ -22,9 +22,15 @@ func TestPinnedTaskPluginChannelTypesUsesPinnedGenerationIndex(t *testing.T) {
Plugin: plugin,
})
assert.Equal(t, []int{constant.ChannelTypeKling}, pinnedTaskPluginChannelTypes(c, "legacy-select"))
assert.Empty(t, pinnedTaskPluginChannelTypes(c, "another-plugin"))
assert.Empty(t, pinnedTaskPluginChannelTypes(nil, "legacy-select"))
types, keys := pinnedTaskPluginIdentities(c, "legacy-select")
assert.Equal(t, []int{constant.ChannelTypeKling}, types)
assert.Equal(t, []string{"legacy-select"}, keys)
types, keys = pinnedTaskPluginIdentities(c, "another-plugin")
assert.Empty(t, types)
assert.Empty(t, keys)
types, keys = pinnedTaskPluginIdentities(nil, "legacy-select")
assert.Empty(t, types)
assert.Empty(t, keys)
}
func TestPinnedTaskPluginChannelTypesLeavesGenericChannelsKeyed(t *testing.T) {
@@ -38,7 +44,9 @@ func TestPinnedTaskPluginChannelTypesLeavesGenericChannelsKeyed(t *testing.T) {
Plugin: plugin,
})
assert.Empty(t, pinnedTaskPluginChannelTypes(c, "generic-select"))
types, keys := pinnedTaskPluginIdentities(c, "generic-select")
assert.Empty(t, types)
assert.Equal(t, []string{"generic-select"}, keys)
}
func TestPinnedTaskPluginChannelTypesIncludesSharedEndpointProviders(t *testing.T) {
@@ -64,7 +72,11 @@ func TestPinnedTaskPluginChannelTypesIncludesSharedEndpointProviders(t *testing.
Candidates: candidates,
})
assert.Equal(t, []int{constant.ChannelTypeGemini, constant.ChannelTypeVertexAi}, pinnedTaskPluginChannelTypes(c, candidates[0].Plugin.Meta.Key))
AppendTaskPluginIdentityFilter(c, candidates[0].Plugin.Meta.Key)
filters := GetChannelConstraints(c).Filters
require.Len(t, filters, 1)
assert.Equal(t, []int{constant.ChannelTypeGemini, constant.ChannelTypeVertexAi}, filters[0].TaskPluginChannelTypes)
assert.Equal(t, []string{"gemini-select", "vertex-select"}, filters[0].TaskPluginKeys)
}
func channelSelectTaskPluginSource(key string, channelType int) string {
@@ -129,7 +141,9 @@ func TestPinnedTaskPluginChannelTypesIncludesCompatibleTypes(t *testing.T) {
Plugin: plugin,
})
assert.Equal(t, []int{constant.ChannelTypeSora, constant.ChannelTypeOpenAI}, pinnedTaskPluginChannelTypes(c, "sora-select"))
types, keys := pinnedTaskPluginIdentities(c, "sora-select")
assert.Equal(t, []int{constant.ChannelTypeSora, constant.ChannelTypeOpenAI}, types)
assert.Equal(t, []string{"sora-select"}, keys)
}
func channelSelectCompatiblePluginSource(key string, channelType, compatibleType int) string {
@@ -150,3 +164,22 @@ export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {status: "SUCCESS"}; }
`, key, key, channelType, compatibleType)
}
func TestSharedType61IdentityFilterContainsAllCandidateKeys(t *testing.T) {
registry := jsplugin.NewRegistry()
for _, key := range []string{"alpha", "beta"} {
_, err := registry.Register(channelSelectEndpointPluginSource(key, 0), jsplugin.Options{})
require.NoError(t, err)
}
generation := registry.Generation()
candidates := generation.LookupEndpointCandidates("POST", "/v1/responses", "task-model")
require.Len(t, candidates, 2)
c, _ := gin.CreateTestContext(nil)
c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{Generation: generation, Plugin: candidates[0].Plugin, Candidates: candidates})
AppendTaskPluginIdentityFilter(c, "alpha")
filters := GetChannelConstraints(c).Filters
require.Len(t, filters, 1)
assert.Equal(t, "alpha", filters[0].TaskPluginKey)
assert.Equal(t, []string{"alpha", "beta"}, filters[0].TaskPluginKeys)
assert.Empty(t, filters[0].TaskPluginChannelTypes)
}
+84
View File
@@ -0,0 +1,84 @@
package service
import (
"errors"
"fmt"
"math"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/billing_setting"
)
type DetectionPrice struct {
Snapshot billingexpr.BillingSnapshot `json:"snapshot"`
Request billingexpr.RequestInput `json:"request"`
}
func DetectionPricing(userGroup, group, kind string) (DetectionPrice, int, error) {
expression, ok := billing_setting.GetBillingExprForGroup(group, fingerprint.Model)
if !ok {
snapshot, err := model.GetModelPricingSnapshot([]string{fingerprint.Model})
if err != nil || snapshot == nil || len(snapshot.Entries) != 1 {
return DetectionPrice{}, 0, errors.New("pricing unavailable")
}
converted, err := model.PreviewModelPricingConversion(fingerprint.Model, snapshot.Entries[0].Configured)
if err != nil || converted == nil || converted.Expression == "" {
return DetectionPrice{}, 0, errors.New("unsupported detection pricing")
}
expression = converted.Expression
}
ratio := GetUserGroupRatio(userGroup, group)
if ratio < 0 || math.IsNaN(ratio) || math.IsInf(ratio, 0) {
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}`)
}
cost, err := billingexpr.DetectionCostBound(expression, 32768, float64(output))
if err != nil {
return price, 0, err
}
upper, upperClamp := common.QuotaRoundChecked(math.Ceil(cost / 1_000_000 * common.QuotaPerUnit * ratio))
if upperClamp != nil {
return price, 0, errors.New("reservation exceeds quota limit")
}
reserve, clamp := common.QuotaFromFloatChecked(float64(upper) * float64(calls))
if clamp != nil {
return price, 0, errors.New("reservation exceeds quota limit")
}
return price, reserve, nil
}
func DetectionUsageQuota(priceJSON string, input, output, cached int64, cacheWrites ...int64) (int, error) {
written := int64(0)
if len(cacheWrites) > 0 {
written = cacheWrites[0]
}
if input < 0 || output < 0 || cached < 0 || written < 0 || cached > input || written > input-cached || input > 32768 || output > 16384 {
return 0, errors.New("invalid upstream usage")
}
var price DetectionPrice
if err := common.UnmarshalJsonStr(priceJSON, &price); err != nil {
return 0, err
}
usage := &dto.Usage{PromptTokens: int(input), CompletionTokens: int(output)}
usage.PromptTokensDetails.CachedTokens = int(cached)
usage.PromptTokensDetails.CachedCreationTokens = int(written)
params := BuildTieredTokenParams(usage, false, billingexpr.UsedVarsByHash(price.Snapshot.ExprString, price.Snapshot.ExprHash))
result, err := billingexpr.ComputeTieredQuotaWithRequest(&price.Snapshot, params, price.Request)
if err != nil || result.Clamp != nil || result.ActualQuotaAfterGroup < 0 {
return 0, errors.New("usage settlement failed")
}
return result.ActualQuotaAfterGroup, nil
}
+195
View File
@@ -0,0 +1,195 @@
package service
import (
"context"
"sort"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
type automaticDetectionResult struct {
channelID int
id string
at int64
outcome string
}
func FingerprintScheduleOutcome(status string) string {
switch status {
case "match":
return "normal"
case "mismatch":
return "degraded"
default:
return "unknown"
}
}
// ReconcileAutomaticDetectionSchedules seeds pre-upgrade history once and
// recovers results persisted before a worker lost its lease or process.
func ReconcileAutomaticDetectionSchedules(ctx context.Context, channels []model.Channel, kind string) (map[int]model.DetectionSchedule, map[int]bool, error) {
states := map[int]model.DetectionSchedule{}
active := map[int]bool{}
if len(channels) == 0 {
return states, active, nil
}
ids := make([]int, 0, len(channels))
for _, c := range channels {
ids = append(ids, c.Id)
}
var rows []model.DetectionSchedule
if err := model.DB.WithContext(ctx).Where("channel_id IN ? AND kind = ?", ids, kind).Find(&rows).Error; err != nil {
return nil, nil, err
}
cutoff := time.Now().Unix()
for _, row := range rows {
states[row.ChannelID] = row
cutoff = min(cutoff, row.LastFinishedAt)
}
if len(rows) != len(ids) {
cutoff = 0
}
var results []automaticDetectionResult
if kind == "fingerprint" {
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
}
for _, task := range tasks {
var payload struct {
Force bool `json:"force"`
}
if task.DecodePayload(&payload) != nil || payload.Force {
continue
}
var state AstraSamplingState
if task.DecodeState(&state) != nil {
continue
}
terminal := task.Status == model.SystemTaskStatusSucceeded || task.Status == model.SystemTaskStatusFailed
for _, round := range state.Rounds {
if round.FinishedAt == 0 && !terminal {
active[round.ChannelID] = true
continue
}
at, outcome := round.FinishedAt, FingerprintScheduleOutcome(AstraSamplingResultStatus(round.Result))
if at == 0 {
at, outcome = task.UpdatedAt, "unknown"
}
results = append(results, automaticDetectionResult{round.ChannelID, round.ID, at, outcome})
}
}
} else {
var runs []model.DetectionRun
if err := model.DB.WithContext(ctx).Select("id", "channel_id", "status", "quality", "settled", "finished_at").Where("channel_id IN ? AND user_id = 0 AND kind = ? AND (finished_at >= ? OR settled = ?)", ids, kind, cutoff, false).Find(&runs).Error; err != nil {
return nil, nil, err
}
for _, run := range runs {
if !run.Settled {
active[run.ChannelID] = true
continue
}
outcome := "unknown"
if run.Status == "succeeded" && (run.Quality == "normal" || run.Quality == "degraded") {
outcome = run.Quality
}
results = append(results, automaticDetectionResult{run.ChannelID, run.ID, run.FinishedAt, outcome})
}
}
sort.SliceStable(results, func(i, j int) bool {
if results[i].at == results[j].at {
return results[i].id < results[j].id
}
return results[i].at < results[j].at
})
for _, channel := range channels {
if _, exists := states[channel.Id]; exists {
continue
}
seed := model.DetectionSchedule{ChannelID: channel.Id, Kind: kind, IntervalMinutes: 30}
for _, result := range results {
if result.channelID != channel.Id {
continue
}
seed.LastFinishedAt, seed.LastResultID = result.at, result.id
seed.IntervalMinutes = 15
if result.outcome == "normal" || result.outcome == "degraded" {
seed.LastValid = result.outcome
seed.IntervalMinutes = 30
}
seed.NextDueAt = result.at + int64(seed.IntervalMinutes)*60
}
if err := model.EnsureDetectionSchedule(ctx, seed); err != nil {
return nil, nil, err
}
}
for _, result := range results {
// Newly seeded clocks already represent the latest historical result.
if state, existed := states[result.channelID]; !existed || result.at <= state.LastFinishedAt {
continue
}
if err := model.ApplyDetectionScheduleResult(ctx, result.channelID, kind, result.id, result.at, result.outcome); err != nil {
return nil, nil, err
}
}
rows = nil
if err := model.DB.WithContext(ctx).Where("channel_id IN ? AND kind = ?", ids, kind).Find(&rows).Error; err != nil {
return nil, nil, err
}
for _, row := range rows {
states[row.ChannelID] = row
}
return states, active, nil
}
func AutomaticDetectionChannelsDue(ctx context.Context, kind string, now time.Time) ([]model.Channel, map[int]model.DetectionSchedule, error) {
channels, err := AstraSamplingChannels(ctx)
if err != nil {
return nil, nil, err
}
states, active, err := ReconcileAutomaticDetectionSchedules(ctx, channels, kind)
if err != nil {
return nil, nil, err
}
var due []model.Channel
for _, channel := range channels {
if !active[channel.Id] && states[channel.Id].NextDueAt <= now.Unix() {
due = append(due, channel)
}
}
return due, states, nil
}
// Public group summaries expose ranges, never channel identities.
func DetectionScheduleRanges(ctx context.Context, channels []model.Channel, kind string) (map[string][2]int, error) {
var rows []model.DetectionSchedule
if err := model.DB.WithContext(ctx).Where("kind = ?", kind).Find(&rows).Error; err != nil {
return nil, err
}
intervals := map[int]int{}
for _, row := range rows {
intervals[row.ChannelID] = row.IntervalMinutes
}
ranges := map[string][2]int{}
for _, channel := range channels {
if channel.Status != common.ChannelStatusEnabled || !ChannelSupportsAstra(&channel) {
continue
}
interval := intervals[channel.Id]
if interval == 0 {
interval = 30
}
for _, group := range channel.GetGroups() {
r := ranges[group]
if r[0] == 0 {
r = [2]int{interval, interval}
} else {
r = [2]int{min(r[0], interval), max(r[1], interval)}
}
ranges[group] = r
}
}
return ranges, nil
}
+153
View File
@@ -0,0 +1,153 @@
package service
import (
"context"
"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/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func adaptiveTestDB(t *testing.T) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
old := model.DB
model.DB = db
t.Cleanup(func() { model.DB = old; _ = conn.Close() })
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.SystemTask{}, &model.DetectionSchedule{}, &model.DetectionRun{}))
}
func seedAutomaticFingerprint(t *testing.T, id string, at int64, closest string, force bool, running bool) {
t.Helper()
result := fingerprint.Result{Status: "error"}
if closest != "" {
result = fingerprint.Result{Status: "inconclusive", Closest: closest, ValidSamples: 1}
}
finished, status := at, model.SystemTaskStatusSucceeded
if running {
result.Status = "running"
finished = 0
status = model.SystemTaskStatusRunning
}
state, err := common.Marshal(AstraSamplingState{Rounds: []AstraSamplingRound{{ID: id, ChannelID: 1, StartedAt: at - 10, FinishedAt: finished, Result: result}}})
require.NoError(t, err)
payload, err := common.Marshal(map[string]bool{"force": force})
require.NoError(t, err)
require.NoError(t, model.DB.Create(&model.SystemTask{TaskID: id, Type: AstraSamplingTaskType, Status: status, State: string(state), Payload: string(payload), CreatedAt: at - 10, UpdatedAt: at}).Error)
}
func TestAdaptiveFingerprintBootstrapRecoveryAndManualIsolation(t *testing.T) {
adaptiveTestDB(t)
ctx := context.Background()
channel := model.Channel{Id: 1, Status: common.ChannelStatusEnabled, Group: "one,shared", Models: fingerprint.Model}
require.NoError(t, model.DB.Create(&channel).Error)
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))
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))
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))
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))
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))
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))
require.NoError(t, err)
assert.Equal(t, states, again)
seedAutomaticFingerprint(t, "abandoned", 7000, "", false, true)
due, _, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", 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))
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))
require.NoError(t, err)
assert.Equal(t, states, again)
}
func TestAdaptivePelicanBootstrapInFlightAndRanges(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"},
}
require.NoError(t, model.DB.Create(&runs).Error)
due, states, err := AutomaticDetectionChannelsDue(ctx, "pelican", 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")
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))
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))
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))
require.NoError(t, err)
assert.Len(t, due, 2)
}
type adaptiveGatedHandler struct {
stubScheduledHandler
due bool
err error
}
func (h *adaptiveGatedHandler) ScheduleDue(context.Context, time.Time) (bool, error) {
return h.due, h.err
}
func TestAdaptiveSchedulerDoesNotCreateEmptyTasks(t *testing.T) {
truncate(t)
handler := &adaptiveGatedHandler{stubScheduledHandler: stubScheduledHandler{taskType: "adaptive_gate", enabled: true, interval: 15 * time.Second}}
withSystemTaskRegistry(t, handler)
runSystemTaskScheduler()
assert.Equal(t, int64(0), countSystemTasks(t, handler.taskType))
handler.due = true
runSystemTaskScheduler()
runSystemTaskScheduler()
assert.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
}
+249
View File
@@ -0,0 +1,249 @@
package service
import (
"context"
"sort"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
)
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"`
}
type PelicanSummary struct {
Channels int `json:"channels"`
IntervalMinutes int `json:"interval_minutes"`
IntervalMaxMinutes int `json:"interval_max_minutes"`
WindowHours int `json:"window_hours"`
Normal int `json:"normal"`
Degraded int `json:"degraded"`
Unknown int `json:"unknown"`
Failed int `json:"failed"`
Running int `json:"running"`
History []DetectionHistory `json:"history"`
}
type ChannelDetectionEvidence struct {
At int64
Success bool
}
type GroupDetectionSummary struct {
ProbeHistory []ProbeHistoryPoint
Pelican *PelicanSummary
Status int
At int64
}
func DetectionFallbackStatus(ids []int, evidence map[int]ChannelDetectionEvidence, now int64) (int, int64) {
good, bad, at := 0, 0, int64(0)
for _, id := range ids {
e, ok := evidence[id]
if !ok || e.At < now-7200 {
continue
}
at = max(at, e.At)
if e.Success {
good++
} else {
bad++
}
}
if len(ids) > 0 && good == len(ids) {
return 1, at
}
if good > 0 {
return 2, at
}
if len(ids) > 0 && bad == len(ids) {
return 0, at
}
return -1, at
}
func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints map[string]*AstraSamplingSummary, settings map[string]model.StatusPageGroup) (map[string]GroupDetectionSummary, error) {
channels, err := samplingChannels(ctx, false)
if err != nil {
return nil, err
}
groups := map[string][]int{}
byChannel := map[int][]string{}
pelicans := map[string]*PelicanSummary{}
evidence := map[int]ChannelDetectionEvidence{}
observe := func(id int, at int64, success bool) {
if at > evidence[id].At {
evidence[id] = ChannelDetectionEvidence{At: at, Success: success}
}
}
for _, channel := range channels {
byChannel[channel.Id] = channel.GetGroups()
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{}}
}
pelicans[group].Channels++
}
}
}
var tasks []model.SystemTask
ranges, err := DetectionScheduleRanges(ctx, channels, "pelican")
if err != nil {
return nil, err
}
for group, summary := range pelicans {
r := ranges[group]
summary.IntervalMinutes, summary.IntervalMaxMinutes = r[0], r[1]
}
if err = model.DB.WithContext(ctx).Where("type IN ? AND created_at >= ?", []string{AstraSamplingTaskType, model.SystemTaskTypeChannelAvailability}, now.Add(-6*time.Hour).Unix()).Find(&tasks).Error; err != nil {
return nil, err
}
for _, task := range tasks {
if task.Type == AstraSamplingTaskType {
var state AstraSamplingState
if task.DecodeState(&state) != nil {
continue
}
for _, round := range state.Rounds {
if round.FinishedAt <= 0 {
continue
}
if round.Result.ValidSamples > 0 {
observe(round.ChannelID, round.FinishedAt, true)
} else if round.Reason == "transport_error" || round.Reason == "channel_unavailable" {
observe(round.ChannelID, round.FinishedAt, false)
}
}
} else {
var state ChannelAvailabilityState
if task.DecodeState(&state) != nil {
continue
}
for _, round := range state.Rounds {
observe(round.ChannelID, round.At, round.Success)
}
}
}
var runs []model.DetectionRun
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)
}
var calls []model.DetectionCall
if len(ids) > 0 {
if err = model.DB.WithContext(ctx).Select("run_id", "finished_at", "available").Where("run_id IN ? AND state = ?", ids, "finished").Find(&calls).Error; err != nil {
return nil, err
}
}
runChannels := map[string]int{}
for _, run := range runs {
runChannels[run.ID] = run.ChannelID
}
for _, call := range calls {
if call.Available != nil {
observe(runChannels[call.RunID], call.FinishedAt, *call.Available)
}
}
for _, run := range runs {
for _, group := range byChannel[run.ChannelID] {
if cutoff := settings[group].ResetBefore; cutoff > 0 && run.CreatedAt <= cutoff {
continue
}
if run.Kind == "fingerprint" {
summary := fingerprints[group]
if summary == nil || !run.Settled {
continue
}
var result fingerprint.Result
if common.UnmarshalJsonStr(run.Result, &result) != nil {
result.Status = "error"
}
if result.Status == "" {
result.Status = "error"
}
result.Status = AstraSamplingResultStatus(result)
switch result.Status {
case "match":
summary.Matches++
case "mismatch":
summary.Mismatches++
case "inconclusive":
summary.Inconclusive++
default:
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})
continue
}
s := pelicans[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"`
}
if common.UnmarshalJsonStr(run.Result, &metadata) == nil {
history.SourceURL = metadata.SourceURL
}
s.History = append(s.History, history)
switch run.Status {
case "pending", "running":
s.Running++
case "failed":
s.Failed++
default:
switch run.Quality {
case "normal":
s.Normal++
case "degraded":
s.Degraded++
default:
s.Unknown++
}
}
}
}
for _, s := range fingerprints {
if n := s.Matches + s.Mismatches; n > 0 {
rate := float64(s.Mismatches) / float64(n)
s.Rate = &rate
}
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 := map[string]GroupDetectionSummary{}
for group, ids := range groups {
status, at := DetectionFallbackStatus(ids, evidence, now.Unix())
s := pelicans[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)}
}
return results, nil
}
+134
View File
@@ -0,0 +1,134 @@
package service
import (
"sync"
"time"
)
// Group availability is derived from recent real request outcomes instead of an
// active probe. A group whose attempts keep failing is skipped during group
// selection until a cooldown elapses, after which it is eligible again so the
// next request acts as a probe.
const (
groupAvailabilityMaxSamples = 20
groupAvailabilityMinSamples = 4
groupAvailabilityFailRatio = 0.6
groupAvailabilityFailureBurst = 3
groupAvailabilitySampleLifetime = 120 * time.Second
groupAvailabilityCooldown = 20 * time.Second
)
type groupOutcome struct {
at time.Time
success bool
}
type groupAvailabilityState struct {
mu sync.Mutex
outcomes []groupOutcome
}
var (
groupAvailabilityMu sync.RWMutex
groupAvailabilityStates = make(map[string]*groupAvailabilityState)
)
func groupAvailabilityStateFor(group string) *groupAvailabilityState {
groupAvailabilityMu.RLock()
state, ok := groupAvailabilityStates[group]
groupAvailabilityMu.RUnlock()
if ok {
return state
}
groupAvailabilityMu.Lock()
defer groupAvailabilityMu.Unlock()
if state, ok := groupAvailabilityStates[group]; ok {
return state
}
state = &groupAvailabilityState{}
groupAvailabilityStates[group] = state
return state
}
// RecordGroupOutcome records the result of one upstream attempt for a group.
func RecordGroupOutcome(group string, success bool) {
if group == "" {
return
}
state := groupAvailabilityStateFor(group)
state.mu.Lock()
defer state.mu.Unlock()
now := time.Now()
state.outcomes = append(state.outcomes, groupOutcome{at: now, success: success})
trimmed := state.outcomes[:0]
for _, outcome := range state.outcomes {
if now.Sub(outcome.at) <= groupAvailabilitySampleLifetime {
trimmed = append(trimmed, outcome)
}
}
state.outcomes = trimmed
if len(state.outcomes) > groupAvailabilityMaxSamples {
state.outcomes = state.outcomes[len(state.outcomes)-groupAvailabilityMaxSamples:]
}
}
// IsGroupAvailable reports whether a group should be preferred by selection.
// A group without recorded traffic is available, so a fresh group is never
// skipped before it has been tried.
func IsGroupAvailable(group string) bool {
if group == "" {
return true
}
groupAvailabilityMu.RLock()
state, ok := groupAvailabilityStates[group]
groupAvailabilityMu.RUnlock()
if !ok {
return true
}
state.mu.Lock()
defer state.mu.Unlock()
now := time.Now()
samples := make([]groupOutcome, 0, len(state.outcomes))
for _, outcome := range state.outcomes {
if now.Sub(outcome.at) <= groupAvailabilitySampleLifetime {
samples = append(samples, outcome)
}
}
if len(samples) == 0 {
return true
}
last := samples[len(samples)-1]
if last.success {
return true
}
if now.Sub(last.at) >= groupAvailabilityCooldown {
return true
}
burst := 0
for i := len(samples) - 1; i >= 0; i-- {
if samples[i].success {
break
}
burst++
}
if burst >= groupAvailabilityFailureBurst {
return false
}
if len(samples) < groupAvailabilityMinSamples {
return true
}
failures := 0
for _, sample := range samples {
if !sample.success {
failures++
}
}
return float64(failures)/float64(len(samples)) < groupAvailabilityFailRatio
}
// ResetGroupAvailability clears all tracked state. Intended for tests.
func ResetGroupAvailability() {
groupAvailabilityMu.Lock()
defer groupAvailabilityMu.Unlock()
groupAvailabilityStates = make(map[string]*groupAvailabilityState)
}
+153
View File
@@ -0,0 +1,153 @@
package service
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func setupGroupPoolContext(t *testing.T) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenCrossGroupRetry, true)
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
common.SetContextKey(ctx, constant.ContextKeyTokenGroupPool, []model.TokenGroupEntry{{Group: "vip", Priority: 1}})
return ctx
}
func TestBuildGroupPoolTiersOrdersByPriorityAndPrependsPrimary(t *testing.T) {
setupChannelSelectAutoGroupsTest(t)
ctx := setupGroupPoolContext(t)
tiers := buildGroupPoolTiers(ctx, "default", "default")
require.Len(t, tiers, 2)
require.Equal(t, []string{"default"}, tiers[0].groups)
require.Equal(t, []string{"vip"}, tiers[1].groups)
require.Less(t, tiers[0].priority, tiers[1].priority)
}
func TestBuildGroupPoolTiersGroupsEqualPriorityTogether(t *testing.T) {
setupChannelSelectAutoGroupsTest(t)
ctx := setupGroupPoolContext(t)
common.SetContextKey(ctx, constant.ContextKeyTokenGroupPool, []model.TokenGroupEntry{
{Group: "default", Priority: 1},
{Group: "vip", Priority: 1},
})
tiers := buildGroupPoolTiers(ctx, "default", "default")
require.Len(t, tiers, 1)
require.Equal(t, []string{"default", "vip"}, tiers[0].groups)
}
func TestNextTierIndexAlternatesWithinTier(t *testing.T) {
key := "round-robin-" + t.Name()
require.Equal(t, 0, nextTierIndex(key, 2))
require.Equal(t, 1, nextTierIndex(key, 2))
require.Equal(t, 0, nextTierIndex(key, 2))
require.Equal(t, 1, nextTierIndex(key, 2))
require.Equal(t, 0, nextTierIndex(key, 1))
}
func TestGroupPoolSwitchesGroupAfterRequestFailure(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "group-pool-switch-after-failure"
createChannelSelectAutoGroupsChannel(t, db, 3101, "default", modelName)
createChannelSelectAutoGroupsChannel(t, db, 3102, "vip", modelName)
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
ctx := setupGroupPoolContext(t)
param := &RetryParam{Ctx: ctx, TokenGroup: "default", ModelName: modelName}
first, firstGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, first)
require.Equal(t, 3101, first.Id)
require.Equal(t, "default", firstGroup)
MarkRequestGroupFailed(ctx, firstGroup)
second, secondGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, second)
require.Equal(t, 3102, second.Id)
require.Equal(t, "vip", secondGroup)
}
func TestGroupPoolAdvancesTierOnRetry(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "group-pool-advance-on-retry"
createChannelSelectAutoGroupsChannel(t, db, 3301, "default", modelName)
createChannelSelectAutoGroupsChannel(t, db, 3302, "vip", modelName)
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
ctx := setupGroupPoolContext(t)
param := &RetryParam{Ctx: ctx, TokenGroup: "default", ModelName: modelName}
first, firstGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, first)
require.Equal(t, 3301, first.Id)
require.Equal(t, "default", firstGroup)
param.IncreaseRetry()
second, secondGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, second)
require.Equal(t, 3302, second.Id)
require.Equal(t, "vip", secondGroup)
}
func TestGroupPoolSkipsUnavailableGroup(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "group-pool-skip-unavailable"
createChannelSelectAutoGroupsChannel(t, db, 3201, "default", modelName)
createChannelSelectAutoGroupsChannel(t, db, 3202, "vip", modelName)
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
ctx := setupGroupPoolContext(t)
for i := 0; i < groupAvailabilityFailureBurst; i++ {
RecordGroupOutcome("default", false)
}
require.False(t, IsGroupAvailable("default"))
param := &RetryParam{Ctx: ctx, TokenGroup: "default", ModelName: modelName}
channel, group, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, channel)
require.Equal(t, 3202, channel.Id)
require.Equal(t, "vip", group)
}
func TestGroupAvailabilityRecoversAfterCooldown(t *testing.T) {
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
for i := 0; i < groupAvailabilityFailureBurst; i++ {
RecordGroupOutcome("recovery-group", false)
}
require.False(t, IsGroupAvailable("recovery-group"))
state := groupAvailabilityStateFor("recovery-group")
state.mu.Lock()
for i := range state.outcomes {
state.outcomes[i].at = state.outcomes[i].at.Add(-groupAvailabilityCooldown)
}
state.mu.Unlock()
require.True(t, IsGroupAvailable("recovery-group"))
}
+94
View File
@@ -0,0 +1,94 @@
package service
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
// PrepareImageBillingForRequest reserves the effective outbound image quantity
// before each attempt, including channel retries and parameter overrides. The
// client request body stays frozen; only the independent quantity is refreshed.
func PrepareImageBillingForRequest(c *gin.Context, info *relaycommon.RelayInfo, count int, promptExtend bool) *types.NewAPIError {
if count < 1 || count > dto.MaxImageN {
return types.NewErrorWithStatusCode(fmt.Errorf("image_count must be an integer between 1 and %d", dto.MaxImageN), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
info.ImageRequestCount = count
var quota int
var err error
if snap := info.TieredBillingSnapshot; snap != nil && snap.BillingMode == "tiered_expr" {
// Token-only image expressions must not acquire a quantity multiplier.
if snap.EstimatedImageCount == nil {
return nil
}
request := billingexpr.RequestInput{}
if info.BillingRequestInput != nil {
request = *info.BillingRequestInput
}
request.ImageCount = &count
cost, trace, runErr := billingexpr.RunExprByHashWithRequest(snap.ExprString, snap.ExprHash, billingexpr.TokenParams{
P: float64(snap.EstimatedPromptTokens), C: float64(snap.EstimatedCompletionTokens), Len: float64(snap.EstimatedPromptTokens),
}, request)
if runErr != nil {
return types.NewErrorWithStatusCode(runErr, types.ErrorCodeModelPriceError, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
beforeGroup := cost / 1_000_000 * snap.QuotaPerUnit
quota, err = billingexpr.QuotaRoundStrict(beforeGroup * info.PriceData.GroupRatioInfo.GroupRatio)
if err == nil {
snap.EstimatedImageCount = trace.ImageCount
snap.EstimatedQuotaBeforeGroup = beforeGroup
snap.EstimatedQuotaAfterGroup = quota
snap.EstimatedTier = trace.MatchedTier
snap.EstimatedBillingUnit = trace.BillingUnit
snap.EstimatedFixedPrice = trace.FixedPrice
snap.GroupRatio = info.PriceData.GroupRatioInfo.GroupRatio
}
} else {
quantity := 1
if info.PriceData.UsePrice || info.ChannelType == constant.ChannelTypeAli {
quantity = count
}
// Overwrite per-attempt ratios so a failed Ali attempt cannot leak its
// quantity or prompt-extension surcharge into another channel.
info.PriceData.AddOtherRatio("n", float64(quantity))
extensionRatio := 1.0
if info.ChannelType == constant.ChannelTypeAli && strings.Contains(info.UpstreamModelName, "z-image") && promptExtend {
extensionRatio = common.ZImagePromptExtendMultiplier
}
info.PriceData.AddOtherRatio("prompt_extend", extensionRatio)
base := info.ImageQuotaBeforeGroup
if info.PriceData.UsePrice {
base = info.PriceData.ModelPrice * common.QuotaPerUnit
}
quota, err = common.QuotaFromFloatStrict(info.PriceData.ApplyOtherRatiosToFloat(base * info.PriceData.GroupRatioInfo.GroupRatio))
}
if err != nil {
return types.NewErrorWithStatusCode(err, types.ErrorCodeModelPriceError, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
info.PriceData.QuotaToPreConsume = quota
if quota == 0 && info.Billing == nil {
return nil
}
info.PriceData.FreeModel = false
if info.Billing == nil {
return PreConsumeBilling(c, quota, info)
}
if err := info.Billing.Reserve(quota); err != nil {
var apiErr *types.NewAPIError
if errors.As(err, &apiErr) {
return apiErr
}
return types.NewErrorWithStatusCode(err, types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry())
}
info.FinalPreConsumedQuota = info.Billing.GetPreConsumedQuota()
return nil
}
+51 -1
View File
@@ -72,6 +72,7 @@ func AppendRelayLogAdminInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
return
}
other.SetAdmin("use_channel", ctx.GetStringSlice("use_channel"))
appendRequestDetails(ctx, relayInfo, other)
if relayInfo != nil {
if billingModel := relayInfo.GetBillingModelName(); billingModel != "" && billingModel != relayInfo.OriginModelName {
other.SetAdmin("billing_model", billingModel)
@@ -92,10 +93,14 @@ func AppendRelayLogAdminInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
AppendChannelAffinityAdminInfo(ctx, other)
if events := RequestPolicy(ctx).Events(); len(events) > 0 {
other.SetAdmin("request_policy", events)
}
}
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) }
other := model.NewLogOther()
other.SetPublic("model_ratio", modelRatio)
other.SetPublic("group_ratio", groupRatio)
@@ -119,6 +124,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
}
AppendRelayLogAdminInfo(ctx, relayInfo, other)
AppendResponseModelLogInfo(relayInfo, other)
appendRequestPath(ctx, relayInfo, other)
appendRequestConversionChain(relayInfo, other)
appendFinalRequestFormat(relayInfo, other)
@@ -128,6 +134,20 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
return other
}
func AppendResponseModelLogInfo(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || relayInfo.ResponseModel == nil || other == nil {
return
}
observation := relayInfo.ResponseModel
if !observation.Mismatch &&
observation.ReturnedModel == observation.RequestedModel &&
(observation.UpstreamModel == "" || observation.UpstreamModel == observation.RequestedModel) &&
(relayInfo.ChannelMeta == nil || !relayInfo.IsModelMapped) {
return
}
other.SetPublic("response_model", *observation)
}
func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 {
return
@@ -141,13 +161,16 @@ func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other *model.LogOther)
}
ss := relayInfo.StreamStatus
status := "ok"
if ss.HasErrors() || (ss.EndReason != relaycommon.StreamEndReasonClientGone && !ss.IsNormalEnd()) {
if ss.HasErrors() || ss.ResponseFailed() || (ss.EndReason != relaycommon.StreamEndReasonClientGone && !ss.IsNormalEnd()) {
status = "error"
}
streamInfo := map[string]any{
"status": status,
"end_reason": string(ss.EndReason),
}
if outcome := ss.ResponseOutcome(); outcome != "" {
streamInfo["response_status"] = outcome
}
if ss.EndError != nil {
streamInfo["end_error"] = ss.EndError.Error()
}
@@ -322,9 +345,36 @@ func InjectTieredBillingInfo(other *model.LogOther, relayInfo *relaycommon.Relay
other.SetPublic("billing_mode", "tiered_expr")
other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
if result != nil {
if tokens := result.BillingTokens; tokens != nil && result.BillingUnit == billingexpr.BillingUnitToken {
other.SetPublic("image_cache_tokens", tokens.ImgCR)
other.SetPublic("billing_tokens", map[string]float64{
"p": tokens.P, "c": tokens.C, "len": tokens.Len,
"cr": tokens.CR, "cc": tokens.CC, "cc1h": tokens.CC1h,
"img": tokens.Img, "img_cr": tokens.ImgCR, "img_o": tokens.ImgO,
"ai": tokens.AI, "ao": tokens.AO,
})
}
if result.ImageCount != nil {
other.SetPublic("image_count", *result.ImageCount)
}
other.SetPublic("matched_tier", result.MatchedTier)
if result.BillingUnit != "" {
other.SetPublic("billing_unit", result.BillingUnit)
}
if result.FixedPrice != nil {
other.SetPublic("fixed_price", *result.FixedPrice)
}
if len(result.RequestRules) > 0 {
other.SetPublic("request_rules", result.RequestRules)
}
} else if snap.EstimatedBillingUnit != "" {
if snap.EstimatedImageCount != nil {
other.SetPublic("image_count", *snap.EstimatedImageCount)
}
other.SetPublic("matched_tier", snap.EstimatedTier)
other.SetPublic("billing_unit", snap.EstimatedBillingUnit)
if snap.EstimatedFixedPrice != nil {
other.SetPublic("fixed_price", *snap.EstimatedFixedPrice)
}
}
}
+56 -16
View File
@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"gorm.io/gorm"
)
const LoginVerificationTTL = 5 * time.Minute
@@ -16,9 +17,21 @@ type LoginChallenge struct {
Methods []VerificationMethodOption `json:"methods"`
}
// LegacyGitHubMigration is a GitHub binding rewrite from the stored login name
// to the numeric account ID. On the login path it waits for the login
// verification and is written in the transaction that issues the session.
type LegacyGitHubMigration struct {
GitHubID string
LegacyID string
}
// loginFlowPayload stays comparable; completion compares it with the bound copy.
type loginFlowPayload struct {
AuthVersion int64 `json:"auth_version"`
LoginMethod string `json:"login_method"`
// The GitHub binding rewrite waiting for this verification, if any.
PendingGitHubID string `json:"pending_github_id,omitempty"`
PendingGitHubLegacyID string `json:"pending_github_legacy_id,omitempty"`
}
// LoginVerification is server-owned state read from a primary-authenticated flow.
@@ -29,7 +42,7 @@ type LoginVerification struct {
payload loginFlowPayload
}
func StartLoginVerification(user *model.User, loginMethod string) (*LoginChallenge, error) {
func StartLoginVerification(user *model.User, loginMethod string, migration *LegacyGitHubMigration) (*LoginChallenge, error) {
if user == nil || user.Id <= 0 || user.AuthVersion <= 0 || loginMethod == "" {
return nil, model.ErrAuthFlowInvalid
}
@@ -42,6 +55,11 @@ func StartLoginVerification(user *model.User, loginMethod string) (*LoginChallen
}
methods, err := securityVerificationPolicy(VerificationScopeLogin, *state)
if err != nil || len(methods) == 0 {
if err == nil && migration != nil {
// The rewrite is written only after a verification, so a login that
// no longer requires one cannot carry it.
return nil, ErrVerificationUnavailable
}
return nil, err
}
available := false
@@ -51,14 +69,18 @@ func StartLoginVerification(user *model.User, loginMethod string) (*LoginChallen
if !available {
return nil, ErrVerificationUnavailable
}
payload, err := common.Marshal(loginFlowPayload{AuthVersion: state.AuthVersion, LoginMethod: loginMethod})
payload := loginFlowPayload{AuthVersion: state.AuthVersion, LoginMethod: loginMethod}
if migration != nil {
payload.PendingGitHubID, payload.PendingGitHubLegacyID = migration.GitHubID, migration.LegacyID
}
encoded, err := common.Marshal(payload)
if err != nil {
return nil, err
}
expiresAt := time.Now().Add(LoginVerificationTTL)
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeLoginVerification, UserId: user.Id,
Payload: string(payload), ExpiresAt: expiresAt,
Payload: string(encoded), ExpiresAt: expiresAt,
})
if err != nil {
return nil, err
@@ -105,45 +127,63 @@ func requireLoginVerificationMethod(state *model.UserVerificationState, method s
return ErrProofMethod
}
func VerifyLoginCode(token, code, ip, userAgent string) (*AuthBundle, error) {
func VerifyLoginCode(token, code, ip, userAgent string) (*AuthBundle, *LegacyGitHubMigration, error) {
verification, err := RequireLoginVerification(token, VerificationMethodTwoFA)
if err != nil {
return nil, err
return nil, nil, err
}
twoFA, err := model.GetTwoFAByUserId(verification.State.UserID)
if err != nil {
return nil, err
return nil, nil, err
}
if err := VerifyTwoFactorCode(twoFA, code); err != nil {
return nil, err
return nil, nil, err
}
return CompleteLoginVerification(token, verification, VerificationMethodTwoFA, ip, userAgent)
}
// CompleteLoginVerification must only run after a concrete factor ceremony.
// Recheck the bound version and method while consuming the flow and creating the
// session atomically; a different request cannot reuse this authorization.
func CompleteLoginVerification(token string, verification *LoginVerification, method, ip, userAgent string) (*AuthBundle, error) {
// session atomically; a different request cannot reuse this authorization. The
// returned migration is non-nil when the flow's pending GitHub binding rewrite
// was written together with the session.
func CompleteLoginVerification(token string, verification *LoginVerification, method, ip, userAgent string) (*AuthBundle, *LegacyGitHubMigration, error) {
if verification == nil || verification.Flow == nil || verification.State == nil {
return nil, model.ErrAuthFlowInvalid
return nil, nil, model.ErrAuthFlowInvalid
}
session, refreshSecret, err := newLoginSession(verification.State.UserID, verification.payload.AuthVersion, verification.payload.LoginMethod, ip, userAgent)
if err != nil {
return nil, err
return nil, nil, err
}
if err := model.CreateUserSessionFromLoginFlow(token, session, func(flow *model.AuthFlow, state *model.UserVerificationState) error {
var migration *LegacyGitHubMigration
if err := model.CreateUserSessionFromLoginFlow(token, session, func(tx *gorm.DB, flow *model.AuthFlow, state *model.UserVerificationState) error {
var payload loginFlowPayload
if flow.Id != verification.Flow.Id || common.UnmarshalJsonStr(flow.Payload, &payload) != nil || payload != verification.payload {
return model.ErrAuthFlowInvalid
}
return requireLoginVerificationMethod(state, method)
if err := requireLoginVerificationMethod(state, method); err != nil {
return err
}
if payload.PendingGitHubID == "" {
return nil
}
// A binding that was relinked while the challenge was open is left as it
// is and the login continues without the rewrite.
written, err := model.MigrateLegacyGitHubBindingWithTx(tx, state.UserID, payload.PendingGitHubLegacyID, payload.PendingGitHubID)
if err != nil {
return err
}
if written {
migration = &LegacyGitHubMigration{GitHubID: payload.PendingGitHubID, LegacyID: payload.PendingGitHubLegacyID}
}
return nil
}); err != nil {
return nil, err
return nil, nil, err
}
bundle, err := issueAuthBundle(session, session.SID+"."+refreshSecret, true)
if err != nil {
_, _ = model.RevokeUserSession(session.UserID, session.SID, "token_issue_failed")
return nil, err
return nil, nil, err
}
return bundle, nil
return bundle, migration, nil
}
+29 -5
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -273,6 +272,31 @@ func ConvertSimpleChangeParams(content string) *dto.MidjourneyRequest {
return changeParams
}
// RecordMidjourneyPolicyResponse distinguishes accepted tasks from errors inside
// HTTP 200 responses. Submissions are single-attempt: an ambiguous transport
// failure must never create a duplicate task on another channel.
func RecordMidjourneyPolicyResponse(c *gin.Context, response *dto.MidjourneyResponseWithStatusCode, requestErr error) bool {
if response == nil {
response = MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response", http.StatusBadGateway)
}
accepted := requestErr == nil && response.StatusCode == http.StatusOK && (response.Response.Code == 1 || response.Response.Code == 21 || response.Response.Code == 22)
if accepted {
properties, _ := response.Response.Properties.(map[string]any)
if properties["status"] != "FAILURE" {
return true
}
}
state := RequestPolicy(c)
event := PolicyEvent{ChannelID: c.GetInt("channel_id"), Status: response.StatusCode, ErrorCode: strconv.Itoa(response.Response.Code), ErrorSource: "upstream", Decision: PolicyDecision{Action: "failure", Reason: "upstream_failure", Source: "upstream"}}
state.AddEvent(event)
event.Decision, event.Health = PolicyDecision{Action: "stop", Reason: "non_retryable_error", Source: "system"}, "unchanged"
if accepted {
event.Decision.Reason = "task_accepted"
}
state.AddEvent(event)
return false
}
func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestURL string) (*dto.MidjourneyResponseWithStatusCode, []byte, error) {
var nullBytes []byte
//var requestBody io.Reader
@@ -281,7 +305,7 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
var mapResult map[string]any
// if get request, no need to read request body
if c.Request.Method != "GET" {
err := json.NewDecoder(c.Request.Body).Decode(&mapResult)
err := common.DecodeJson(c.Request.Body, &mapResult)
if err != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_request_body_failed", http.StatusInternalServerError), nullBytes, err
}
@@ -303,7 +327,7 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
mapResult["prompt"] = prompt
}
}
reqBody, err := json.Marshal(mapResult)
reqBody, err := common.Marshal(mapResult)
if err != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "marshal_request_body_failed", http.StatusInternalServerError), nullBytes, err
}
@@ -350,9 +374,9 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
if len(responseBody) == 0 {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil
} else {
err = json.Unmarshal(responseBody, &midjResponse)
err = common.Unmarshal(responseBody, &midjResponse)
if err != nil {
err2 := json.Unmarshal(responseBody, &midjourneyUploadsResponse)
err2 := common.Unmarshal(responseBody, &midjourneyUploadsResponse)
if err2 != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "unmarshal_response_body_failed", statusCode), responseBody, err
}
+90 -6
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"net/url"
"slices"
"strings"
"time"
@@ -16,27 +17,45 @@ import (
webauthn "github.com/go-webauthn/webauthn/webauthn"
)
var ErrRPIDUnavailable = system_setting.ErrPasskeyRPIDUnavailable
// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context.
func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
settings := system_setting.GetPasskeySettings()
if settings == nil {
return nil, errors.New("未找到 Passkey 设置")
}
return BuildWebAuthnForRPID(r, "")
}
// BuildWebAuthnForRPID uses a single configured RP ID, never a list of IDs to
// try against the same signed response. An empty ID is for new registrations.
func BuildWebAuthnForRPID(r *http.Request, selectedRPID string) (*webauthn.WebAuthn, error) {
settings := system_setting.PasskeySettingsSnapshot()
displayName := strings.TrimSpace(settings.RPDisplayName)
if displayName == "" {
displayName = common.SystemName
}
origins, err := resolveOrigins(r, settings)
origins, err := resolveOrigins(r, &settings)
if err != nil {
return nil, err
}
rpID, err := resolveRPID(r, settings, origins)
rpID, err := resolveRPID(r, &settings, origins)
if err != nil {
return nil, err
}
if selectedRPID != "" && selectedRPID != rpID {
if !slices.Contains(settings.RelyingPartyIDs(), selectedRPID) {
return nil, ErrRPIDUnavailable
}
rpID = selectedRPID
}
origins = originsForRPID(origins, rpID)
if len(origins) == 0 {
return nil, ErrRPIDUnavailable
}
if origin := r.Header.Get("Origin"); origin != "" && !protocol.IsOriginInHaystack(origin, origins) {
return nil, ErrRPIDUnavailable
}
selection := protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
@@ -73,6 +92,71 @@ func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
return webauthn.New(config)
}
// BuildLoginWebAuthn gives a known credential's binding precedence over a
// browser's last-successful-domain hint. All choices remain server controlled.
func BuildLoginWebAuthn(r *http.Request, hint, credentialRPID string) (*webauthn.WebAuthn, []string, error) {
settings := system_setting.PasskeySettingsSnapshot()
origins, err := resolveOrigins(r, &settings)
if err != nil {
return nil, nil, err
}
primary, err := resolveRPID(r, &settings, origins)
if err != nil {
return nil, nil, err
}
configured := append([]string{primary}, settings.RelyingPartyIDs()...)
available := []string{}
for _, id := range configured {
if credentialRPID != "" && id != credentialRPID {
continue
}
allowedOrigins := originsForRPID(origins, id)
if len(allowedOrigins) == 0 || (r.Header.Get("Origin") != "" && !protocol.IsOriginInHaystack(r.Header.Get("Origin"), allowedOrigins)) {
continue
}
if !slices.Contains(available, id) {
available = append(available, id)
}
}
if len(available) == 0 {
return nil, nil, ErrRPIDUnavailable
}
selected := credentialRPID
if selected == "" {
selected = hint
}
if selected == "" {
selected = available[0]
}
if !slices.Contains(available, selected) {
return nil, nil, ErrRPIDUnavailable
}
wa, err := BuildWebAuthnForRPID(r, selected)
return wa, available, err
}
func originsForRPID(origins []string, rpID string) []string {
allowed := []string{}
for _, origin := range origins {
parsed, err := url.Parse(origin)
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.User != nil {
continue
}
host := strings.ToLower(parsed.Hostname())
domain := strings.ToLower(rpID)
if !strings.Contains(domain, ".") && domain != "localhost" {
if host == domain && parsed.Scheme == "https" {
allowed = append(allowed, origin)
}
continue
}
if host == domain || strings.HasSuffix(host, "."+domain) {
allowed = append(allowed, origin)
}
}
return allowed
}
func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) {
originsStr := strings.TrimSpace(settings.Origins)
if originsStr != "" {
+6
View File
@@ -72,6 +72,12 @@ func PopSessionDataFlow(token, purpose string, identity model.AuthSessionIdentit
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return err
}
if payload.SessionData.RelyingPartyID == "" || payload.SessionData.Expires.IsZero() {
return model.ErrAuthFlowInvalid
}
if !time.Now().Before(payload.SessionData.Expires) {
return model.ErrAuthFlowExpired
}
if purpose == model.AuthFlowPurposePasskeyLogin {
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"github.com/QuantumNous/new-api/model"
"sort"
)
type ProbeHistoryPoint struct {
At int64 `json:"at"`
Status int `json:"status"`
}
func GroupProbeHistory(ids []int, tasks []model.SystemTask) []ProbeHistoryPoint {
points := []ProbeHistoryPoint{}
for _, task := range tasks {
if task.Type != model.SystemTaskTypeChannelAvailability {
continue
}
var state ChannelAvailabilityState
if task.DecodeState(&state) != nil {
continue
}
evidence := map[int]ChannelDetectionEvidence{}
for _, round := range state.Rounds {
evidence[round.ChannelID] = ChannelDetectionEvidence{At: round.At, Success: round.Success}
}
latest := int64(0)
for _, id := range ids {
latest = max(latest, evidence[id].At)
}
if latest == 0 {
continue
}
status, at := DetectionFallbackStatus(ids, evidence, latest)
points = append(points, ProbeHistoryPoint{At: at, Status: status})
}
sort.Slice(points, func(i, j int) bool { return points[i].At < points[j].At })
if len(points) > 18 {
points = points[len(points)-18:]
}
return points
}
+21
View File
@@ -0,0 +1,21 @@
package service
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"testing"
)
func TestGroupProbeHistoryKeepsLatestEighteen(t *testing.T) {
tasks := []model.SystemTask{}
for i := 20; i > 0; i-- {
state, _ := common.Marshal(ChannelAvailabilityState{Rounds: []ChannelAvailabilityRound{{ChannelID: 1, At: int64(i), Success: i != 20}, {ChannelID: 2, At: int64(i), Success: true}}})
tasks = append(tasks, model.SystemTask{Type: model.SystemTaskTypeChannelAvailability, State: string(state)})
}
history := GroupProbeHistory([]int{1, 2}, tasks)
assert.Len(t, history, 18)
assert.Equal(t, int64(3), history[0].At)
assert.Equal(t, 2, history[17].Status)
assert.Empty(t, GroupProbeHistory([]int{3}, tasks))
}
+7 -6
View File
@@ -11,7 +11,6 @@ import (
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -284,16 +283,20 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData)
}
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
if usage == nil {
usage = &dto.Usage{PromptTokens: relayInfo.GetEstimatePromptTokens(), TotalTokens: relayInfo.GetEstimatePromptTokens()}
}
var tieredUsedVars map[string]bool
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
tieredUsedVars = billingexpr.UsedVars(snap.ExprString)
tieredUsedVars = billingexpr.UsedVarsByHash(snap.ExprString, snap.ExprHash)
}
var tieredResult *billingexpr.TieredResult
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(usage, false, tieredUsedVars))
if tieredOk {
tieredResult = tieredRes
}
fixedPriceBilling := tieredOk && isFixedPriceSettlement(relayInfo, tieredRes)
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
textInputTokens := usage.PromptTokensDetails.TextTokens
@@ -344,7 +347,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
}
// record all the consume log even if quota is 0
if totalTokens == 0 {
if totalTokens == 0 && !fixedPriceBilling {
// in this case, must be some error happened
// we cannot just return, because we may have to return the pre-consumed quota
quota = 0
@@ -384,9 +387,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
Group: relayInfo.UsingGroup,
Other: other,
})
gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens))
})
relayInfo.PerformanceOutputTokens = int64(usage.CompletionTokens)
}
func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
+350
View File
@@ -0,0 +1,350 @@
package service
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func BeginRelayAttempt(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
if !info.DurableAttemptBilling {
return nil
}
reserved := info.PriceData.QuotaToPreConsume
if snap := info.TieredBillingSnapshot; snap != nil {
reserved = snap.EstimatedQuotaAfterGroup
}
price, err := common.Marshal(struct {
Price any
Expression any
}{info.PriceData, info.TieredBillingSnapshot})
if err != nil {
return types.NewError(err, types.ErrorCodeModelPriceError)
}
a := &model.RelayAttempt{AttemptKey: fmt.Sprintf("%s:%d", info.RequestId, info.UpstreamRequests+1), RequestID: info.RequestId, Number: info.UpstreamRequests + 1, UserID: info.UserId, TokenID: info.TokenId, ChannelID: c.GetInt("channel_id"), GroupName: info.UsingGroup, RequestedModel: info.OriginModelName, Reserved: reserved, EstimateQuota: reserved, PriceJSON: string(price), Deadline: time.Now().Add(10 * time.Minute).Unix(), GuardResult: "unchecked"}
if err := model.ReserveRelayAttempt(a, info.UserSetting.BillingPreference); err != nil {
return types.NewErrorWithStatusCode(err, types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry())
}
info.DurableAttemptID = a.ID
info.AttemptDetached = false
info.Billing = nil
info.BillingSource = a.Funding
info.SubscriptionId = a.SubscriptionID
info.FinalPreConsumedQuota = a.Reserved
c.Set("relay_attempt_number", a.Number)
c.Set("attempt_guard_result", "")
c.Set("attempt_returned_model", "")
c.Set("attempt_usage_source", "")
requestContext := c.Request.Context()
go func(id int64) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-requestContext.Done():
return
case <-ticker.C:
r := model.DB.Model(&model.RelayAttempt{}).Where("id = ? AND state <> ?", id, "settled").Update("deadline", time.Now().Add(10*time.Minute).Unix())
if r.Error == nil && r.RowsAffected == 0 {
return
}
}
}
}(a.ID)
return nil
}
// Log and account through one durable transaction instead of the ordinary
// single-generation session. A checkpoint calculates price but never debits.
func resolveAttemptGuardResult(reason string, info *relaycommon.RelayInfo) string {
if reason != "" {
return reason
}
if info != nil && info.ResponseModel != nil && info.ResponseModel.ReturnedModel != "" {
if info.ResponseModel.Mismatch {
return "mismatch_allowed"
}
return "accepted"
}
if info != nil && info.AstraAutoSwitch {
return "accepted"
}
return "unchecked"
}
func recordDurableAttempt(c *gin.Context, info *relaycommon.RelayInfo, usage *dto.Usage, params model.RecordConsumeLogParams) {
number := c.GetInt("relay_attempt_number")
reason := resolveAttemptGuardResult(c.GetString("attempt_guard_result"), info)
if reason == "model_mismatch" {
reason = "mismatch_rejected"
}
returned := c.GetString("attempt_returned_model")
if returned == "" && info.ResponseModel != nil {
returned = info.ResponseModel.ReturnedModel
}
source := c.GetString("attempt_usage_source")
if source == "" {
source = "upstream"
if usage != nil && usage.UsageSource == "estimated" {
source = "estimated"
}
}
status := "verified"
if reason == "unchecked" {
status = "unchecked"
} else if reason == "mismatch_allowed" {
status = "mismatch_allowed"
} else if reason == "transport_error" {
status = "transport_error"
} else if reason != "accepted" {
status = "rejected"
if params.Quota > 0 {
status = "charged_rejection"
}
} else if number > 1 {
status = "switched_success"
}
params.Other.SetPublic("routing_status", status)
params.Other.SetPublic("attempt_usage_source", source)
params.Other.SetPublic("routing_attempt", map[string]any{"request_id": info.RequestId, "number": number, "guard_result": reason, "usage_source": source, "requested_model": info.OriginModelName, "returned_model": returned})
params.Other.SetPublic("response_model", map[string]any{"requested_model": info.OriginModelName, "upstream_model": info.UpstreamModelName, "returned_model": returned, "mismatch": returned != "" && returned != info.OriginModelName})
if reason != "accepted" && reason != "unchecked" {
params.Other.SetPublic("model_guard_result", reason)
}
log := &model.Log{UserId: info.UserId, Username: common.GetContextKeyString(c, constant.ContextKeyUserName), CreatedAt: time.Now().Unix(), Type: model.LogTypeConsume, Content: params.Content, TokenName: params.TokenName, ModelName: params.ModelName, Quota: params.Quota, PromptTokens: params.PromptTokens, CompletionTokens: params.CompletionTokens, UseTime: params.UseTimeSeconds, IsStream: params.IsStream, ChannelId: params.ChannelId, TokenId: params.TokenId, Group: params.Group, RequestId: info.RequestId, Other: params.Other.JSONString(), CorrelationKey: c.GetString("log_correlation_key")}
billable := params.Quota > 0 || params.PromptTokens > 0 || params.CompletionTokens > 0
usageBody, _ := common.Marshal(usage)
logBody, _ := common.Marshal(log)
deadline := time.Now().Add(90 * time.Second).Unix()
if c.GetBool("attempt_checkpoint") {
deadline = time.Now().Add(10 * time.Minute).Unix()
}
if c.GetBool("attempt_checkpoint") {
if err := model.CheckpointRelayAttempt(info.DurableAttemptID, params.Quota, string(usageBody), string(logBody), returned, reason, source, billable, deadline); err != nil {
common.SysError("attempt checkpoint: " + err.Error())
}
return
}
details := model.RelayAttempt{UsageJSON: string(usageBody), ReturnedModel: returned, GuardResult: reason, UsageSource: source, Billable: billable}
if err := model.SettleRelayAttempt(info.DurableAttemptID, params.Quota, log, details); err != nil {
common.SysError("attempt settlement: " + err.Error())
info.AttemptDetached = true
// Retain the authoritative usage in memory during transient DB outages;
// restart recovery can independently use the last durable checkpoint.
id := info.DurableAttemptID
quota := params.Quota
go func() {
for range 60 {
time.Sleep(time.Second)
if model.SettleRelayAttempt(id, quota, log, details) == nil {
return
}
}
}()
return
}
if err := model.DeliverRelayAttemptLogs(); err != nil {
common.SysError("attempt log outbox: " + err.Error())
}
}
type GuardUsageCollector struct {
ctx *gin.Context
info relaycommon.RelayInfo
usage dto.Usage
outputTokens int
observed bool
hasUsage bool
lastCheckpoint time.Time
}
func NewGuardUsageCollector(c *gin.Context, info *relaycommon.RelayInfo) *GuardUsageCollector {
copyInfo := *info
if info.ChannelMeta != nil {
meta := *info.ChannelMeta
copyInfo.ChannelMeta = &meta
}
if info.TieredBillingSnapshot != nil {
snap := *info.TieredBillingSnapshot
copyInfo.TieredBillingSnapshot = &snap
}
copyInfo.ResponseModel = nil
copyInfo.ResponseGuard = nil
copyInfo.ResponsesUsageInfo = nil
copyInfo.StreamStatus = relaycommon.NewStreamStatus()
copyContext := c.Copy()
state := RequestPolicy(c)
copyContext.Set(requestPolicyContextKey, &RequestPolicyState{StartedAt: state.StartedAt, Attempts: state.Attempts, SelectedGroup: state.SelectedGroup, SessionMode: state.SessionMode, SessionModeSource: state.SessionModeSource, RuleName: state.RuleName, events: state.Events()})
return &GuardUsageCollector{ctx: copyContext, info: copyInfo}
}
func (collector *GuardUsageCollector) Observe(payload []byte, stream bool) {
var event dto.ResponsesStreamResponse
if stream {
if common.Unmarshal(payload, &event) != nil {
return
}
} else {
var response dto.OpenAIResponsesResponse
if common.Unmarshal(payload, &response) != nil {
return
}
event.Type = "response.completed"
event.Response = &response
}
if event.Response != nil {
collector.info.ObserveResponseModel(event.Response.Model)
if event.Response.Model != "" {
collector.ctx.Set("attempt_returned_model", event.Response.Model)
if event.Response.Model != collector.info.OriginModelName {
collector.ctx.Set("attempt_guard_result", "model_mismatch")
}
}
if event.Response.Usage != nil && (event.Type == "response.completed" || event.Type == "response.incomplete" || event.Type == "response.failed" || event.Type == "response.cancelled") {
ApplyResponsesUsage(&collector.usage, event.Response.Usage)
collector.hasUsage = true
}
if collector.outputTokens == 0 {
text := ExtractOutputTextFromResponses(event.Response)
if text != "" {
collector.outputTokens = CountTextToken(text, collector.info.OriginModelName)
collector.observed = true
}
}
}
if strings.HasSuffix(event.Type, ".delta") && event.Delta != "" {
collector.observed = true
collector.outputTokens += CountTextToken(event.Delta, collector.info.OriginModelName)
}
if collector.hasUsage || collector.observed || collector.ctx.GetString("attempt_returned_model") != "" && collector.ctx.GetString("attempt_returned_model") != collector.info.OriginModelName {
if time.Since(collector.lastCheckpoint) > time.Second || collector.hasUsage {
collector.persist(true)
collector.lastCheckpoint = time.Now()
}
}
}
func (collector *GuardUsageCollector) persist(checkpoint bool) {
usage := collector.usage
if !collector.hasUsage {
usage.PromptTokens = collector.info.GetEstimatePromptTokens()
usage.CompletionTokens = collector.outputTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
collector.ctx.Set("attempt_usage_source", "estimated")
} else {
collector.ctx.Set("attempt_usage_source", "upstream")
}
collector.ctx.Set("attempt_checkpoint", checkpoint)
PostTextConsumeQuota(collector.ctx, &collector.info, &usage, nil)
}
// Flush the most recent observed output when a stream ends between periodic
// checkpoints, including disconnects and late guard rejection.
func (collector *GuardUsageCollector) CheckpointFinal(reason string) {
if !collector.hasUsage && !collector.observed && reason != "model_mismatch" {
return
}
if reason != "accepted" && reason != "pending" {
collector.ctx.Set("attempt_guard_result", reason)
}
collector.persist(true)
}
func (collector *GuardUsageCollector) FinishRejected(info *relaycommon.RelayInfo, guard *relaycommon.ResponseGuardAttempt, resp *http.Response) bool {
if info.DurableAttemptID == 0 {
return false
}
collector.ctx.Set("attempt_guard_result", guard.Reason)
if guard.Reason != "model_mismatch" && !collector.hasUsage && !collector.observed {
return false
}
info.AttemptDetached = true
collector.persist(true)
guard.Detach()
go func() {
defer func() {
if recovered := recover(); recovered != nil {
common.SysError(fmt.Sprintf("attempt usage collection panic: %v", recovered))
}
}()
defer guard.Close()
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
defer collector.persist(false)
if guard.Drain != nil {
guard.Drain()
}
}()
return true
}
func EndFailedRelayAttempt(c *gin.Context, info *relaycommon.RelayInfo) {
if info.DurableAttemptID == 0 || info.AttemptDetached {
return
}
var attempt model.RelayAttempt
if err := model.DB.First(&attempt, info.DurableAttemptID).Error; err != nil {
common.SysError(err.Error())
return
}
if attempt.State == "settled" {
return
}
if !attempt.Billable {
reason := "failed"
if guard := info.ResponseGuard.Last(); guard != nil {
reason = guard.Reason
}
_ = model.CheckpointRelayAttempt(attempt.ID, 0, "{}", "", attempt.ReturnedModel, reason, "", false, time.Now().Unix())
}
quota := 0
var log *model.Log
if attempt.Billable {
quota = attempt.ObservedQuota
log = &model.Log{}
if common.UnmarshalJsonStr(attempt.LogJSON, log) != nil {
log = nil
}
}
if err := model.SettleRelayAttempt(attempt.ID, quota, log); err != nil {
common.SysError(err.Error())
}
}
func StartRelayAttemptRecovery() {
go func() {
for {
var attempts []model.RelayAttempt
if err := model.DB.Where("state <> ? AND deadline < ?", "settled", time.Now().Unix()).Limit(100).Find(&attempts).Error; err == nil {
for _, attempt := range attempts {
quota := 0
var log *model.Log
if attempt.Billable {
quota = attempt.ObservedQuota
log = &model.Log{}
if common.UnmarshalJsonStr(attempt.LogJSON, log) != nil {
continue
}
}
if err := model.SettleRelayAttempt(attempt.ID, quota, log); err != nil {
common.SysError("attempt recovery: " + err.Error())
}
}
}
if err := model.DeliverRelayAttemptLogs(); err != nil {
common.SysError("attempt outbox: " + err.Error())
}
time.Sleep(5 * time.Second)
}
}()
}
+31
View File
@@ -0,0 +1,31 @@
package service
import (
"testing"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/stretchr/testify/require"
)
func TestResolveAttemptGuardResultForDetectionOnly(t *testing.T) {
t.Run("matching declaration is verified", func(t *testing.T) {
info := &relaycommon.RelayInfo{ResponseModel: &relaycommon.ResponseModel{
RequestedModel: "gpt-6-astra",
ReturnedModel: "gpt-6-astra",
}}
require.Equal(t, "accepted", resolveAttemptGuardResult("", info))
})
t.Run("missing declaration remains unchecked", func(t *testing.T) {
require.Equal(t, "unchecked", resolveAttemptGuardResult("", &relaycommon.RelayInfo{}))
})
t.Run("allowed mismatch remains visible", func(t *testing.T) {
info := &relaycommon.RelayInfo{ResponseModel: &relaycommon.ResponseModel{
RequestedModel: "gpt-6-astra",
ReturnedModel: "gpt-5.6-luna",
Mismatch: true,
}}
require.Equal(t, "mismatch_allowed", resolveAttemptGuardResult("", info))
})
}
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"fmt"
"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"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
)
// DecideRelayRetry is the single retry decision for relay attempts. The reason
// is recorded in the request policy decision events of the log details.
func DecideRelayRetry(c *gin.Context, err *types.NewAPIError, retryTimes int) PolicyDecision {
if err == nil {
return PolicyDecision{Action: "stop", Reason: "request_completed", Source: "system"}
}
if ShouldSkipRetryAfterChannelAffinityFailure(c) {
source := RequestPolicy(c).SessionModeSource
if source == "" {
source = "session_rule"
}
return PolicyDecision{Action: "stop", Reason: "strict_session", Source: source}
}
if GetChannelConstraints(c).SuppressesRetry() {
return PolicyDecision{Action: "stop", Reason: "pinned_channel", Source: "channel_constraint"}
}
if types.IsChannelError(err) {
return PolicyDecision{Action: "retry", Reason: "channel_error", Source: "system"}
}
if types.IsSkipRetryError(err) {
return PolicyDecision{Action: "stop", Reason: "non_retryable_error", Source: "system"}
}
if retryTimes <= 0 {
return PolicyDecision{Action: "stop", Reason: "attempt_budget_exhausted", Source: "global"}
}
code := err.StatusCode
if code >= 200 && code < 300 {
return PolicyDecision{Action: "stop", Reason: "system_retry_exclusion", Source: "system"}
}
if code < 100 || code > 599 {
return PolicyDecision{Action: "retry", Reason: "unrecognized_status", Source: "system"}
}
if operation_setting.IsAlwaysSkipRetryCode(err.GetErrorCode()) || operation_setting.IsAlwaysSkipRetryStatusCode(code) {
return PolicyDecision{Action: "stop", Reason: "system_retry_exclusion", Source: "system"}
}
if operation_setting.ShouldRetryByStatusCode(code) {
return PolicyDecision{Action: "retry", Reason: "retry_status_matched", Source: "global"}
}
return PolicyDecision{Action: "stop", Reason: "status_not_retryable", Source: "global"}
}
func ShouldRetryRelayError(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool {
return DecideRelayRetry(c, openaiErr, retryTimes).Action == "retry"
}
func ProcessChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError, relayInfo *relaycommon.RelayInfo) {
if err == nil {
return
}
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.MaskSensitiveErrorWithStatusCode())))
if err.GetErrorCode() != relaycommon.ResponseGuardErrorCode && ShouldDisableChannel(err) && channelError.AutoBan {
reason := err.MaskSensitiveErrorWithStatusCode()
gopool.Go(func() {
DisableChannel(channelError, reason)
})
}
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
userId := c.GetInt("id")
tokenName := c.GetString("token_name")
modelName := c.GetString("original_model")
tokenId := c.GetInt("token_id")
usingGroup := relayErrorGroup(c, channelError.ChannelId, relayInfo)
other := model.NewLogOther()
if c.Request != nil && c.Request.URL != nil {
other.SetPublic("request_path", c.Request.URL.Path)
}
other.SetPublic("error_type", err.GetErrorType())
other.SetPublic("error_code", err.GetErrorCode())
other.SetPublic("status_code", err.StatusCode)
AppendRelayLogAdminInfo(c, relayInfo, other)
AppendResponseModelLogInfo(relayInfo, other)
AppendTaskPluginContextAuditInfo(c, other)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
other.SetRoot("upstream_error", err.MaskSensitiveErrorWithStatusCode())
content := fmt.Sprintf("status_code=%d, The model service is temporarily unavailable. Please try again later.", err.StatusCode)
model.RecordErrorLog(c, userId, channelError.ChannelId, modelName, tokenName, content, tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), usingGroup, other)
}
}
// A user/token's default group is not necessarily the selected retry group.
func relayErrorGroup(c *gin.Context, channelID int, info *relaycommon.RelayInfo) string {
if channelID <= 0 {
return ""
}
if info != nil && info.UsingGroup != "" && info.UsingGroup != "auto" {
return info.UsingGroup
}
if group := common.GetContextKeyString(c, constant.ContextKeyAutoGroup); group != "" {
return group
}
if group := RequestPolicy(c).SelectedGroup; group != "" {
return group
}
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
if group == "auto" {
return ""
}
return group
}
+291
View File
@@ -0,0 +1,291 @@
package service
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestShouldRetryRelayErrorHonorsChannelPinOnChannelError(t *testing.T) {
err := types.NewError(errors.New("channel failed"), types.ErrorCodeChannelNoAvailableKey)
for _, test := range []struct {
name string
pin *dto.ChannelPin
wantRetry bool
}{
{name: "unrestricted channel error", wantRetry: true},
{
name: "single attempt pin suppresses channel error retry",
pin: &dto.ChannelPin{
ChannelId: 1, Source: dto.PinSourceToken, Rank: dto.PinRankToken, RetryMode: dto.PinRetrySingleAttempt,
},
wantRetry: false,
},
{
name: "origin task pin permits retry on the same channel",
pin: &dto.ChannelPin{
ChannelId: 1, Source: dto.PinSourceOriginTask, Rank: dto.PinRankOriginTask, RetryMode: dto.PinRetrySameChannel,
},
wantRetry: true,
},
} {
t.Run(test.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
if test.pin != nil {
GetChannelConstraints(c).AddPin(*test.pin)
}
assert.Equal(t, test.wantRetry, ShouldRetryRelayError(c, err, 1))
})
}
}
func TestRequestDetailsCorrelationIsolation(t *testing.T) {
makeDetails := func(user int, headers map[string]string) RequestDetails {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Set("id", user)
for key, value := range headers {
c.Request.Header.Set(key, value)
}
other := model.NewLogOther()
appendRequestDetails(c, nil, other)
return other.Snapshot()["admin_info"].(map[string]any)["request_details"].(RequestDetails)
}
a := makeDetails(1, map[string]string{"Session-Id": "shared", "Thread-Id": "ignored"})
require.Equal(t, "session", a.SessionSource)
require.Equal(t, "shared", a.SessionID)
require.Len(t, a.CorrelationKey, 64)
require.Equal(t, a.CorrelationKey, makeDetails(1, map[string]string{"session_id": "shared"}).CorrelationKey)
require.NotEqual(t, a.CorrelationKey, makeDetails(2, map[string]string{"Session-Id": "shared"}).CorrelationKey)
require.NotEqual(t, a.CorrelationKey, makeDetails(1, map[string]string{"Thread-Id": "shared"}).CorrelationKey)
require.Equal(t, "none", makeDetails(1, nil).SessionSource)
require.Empty(t, makeDetails(1, nil).CorrelationKey)
}
func TestRequestDetailsCandidateAttemptLimit(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
for call := 0; call < 2; call++ {
trace := &model.SelectionTrace{}
for i := 0; i < 80; i++ {
trace.Add(&model.Channel{Id: i + 1, Name: "snapshot", Key: "must-not-log"}, i+1, "test", "model", "eligible")
}
recordSelection(c, "test", trace, "priority_weight_selection")
}
events := RequestPolicy(c).Events()
require.Len(t, events[0].Candidates, 80)
require.Len(t, events[1].Candidates, 20)
require.Equal(t, 80, events[1].CandidateTotal)
require.Equal(t, 1, events[0].Attempt)
encoded, err := common.Marshal(events)
require.NoError(t, err)
require.NotContains(t, string(encoded), "must-not-log")
RequestPolicy(c).BeginAttempt(&model.Channel{Id: 1}, "test")
trace := &model.SelectionTrace{}
trace.Add(&model.Channel{Id: 2}, 2, "backup", "model", "selected")
recordSelection(c, "backup", trace, "priority_weight_selection")
RequestPolicy(c).BeginAttempt(&model.Channel{Id: 2}, "backup")
events = RequestPolicy(c).Events()
require.Equal(t, "fallback_group_switch", events[len(events)-2].Decision.Reason)
require.Equal(t, 2, events[len(events)-2].Attempt)
}
func TestProcessChannelErrorMasksDisableReasonAndNotification(t *testing.T) {
previousDB, previousType := model.DB, common.MainDatabaseType()
previousCache, previousRedis := common.MemoryCacheEnabled, common.RedisEnabled
previousAutoDisable, previousErrorLog := common.AutomaticDisableChannelEnabled, constant.ErrorLogEnabled
previousNotifyLimit := constant.NotifyLimitCount
previousClient, previousWorker := httpClient, system_setting.WorkerUrl
fetch := system_setting.GetFetchSetting()
previousFetch := *fetch
t.Cleanup(func() {
model.DB = previousDB
common.SetMainDatabaseType(previousType)
common.MemoryCacheEnabled, common.RedisEnabled = previousCache, previousRedis
common.AutomaticDisableChannelEnabled, constant.ErrorLogEnabled = previousAutoDisable, previousErrorLog
constant.NotifyLimitCount = previousNotifyLimit
httpClient, system_setting.WorkerUrl = previousClient, previousWorker
*fetch = previousFetch
})
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.User{}))
model.DB = database
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.MemoryCacheEnabled, common.RedisEnabled = false, false
common.AutomaticDisableChannelEnabled, constant.ErrorLogEnabled = true, false
constant.NotifyLimitCount = 10
channel := &model.Channel{Name: "relay-review", Key: "fixture-key", Type: 1, Status: common.ChannelStatusEnabled, Group: "default", Models: "test-model"}
require.NoError(t, channel.Insert())
notifications := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
notifications <- body
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
httpClient, system_setting.WorkerUrl = server.Client(), ""
fetch.EnableSSRFProtection = false
settings, err := common.Marshal(kitdto.UserSetting{NotifyType: kitdto.NotifyTypeWebhook, WebhookUrl: server.URL})
require.NoError(t, err)
root := &model.User{Username: "notification-test-root", Role: common.RoleRootUser, Status: common.UserStatusEnabled, Setting: string(settings)}
require.NoError(t, database.Create(root).Error)
notifyKey := fmt.Sprintf("%d:%s:%s", root.Id, formatNotifyType(channel.Id, common.ChannelStatusAutoDisabled), time.Now().Format("2006010215"))
notifyLimitStore.Delete(notifyKey)
t.Cleanup(func() { notifyLimitStore.Delete(notifyKey) })
c, _ := gin.CreateTestContext(httptest.NewRecorder())
apiErr := types.NewErrorWithStatusCode(errors.New("upstream https://private.example.com/path?token=review-token api_key:review-secret"), types.ErrorCodeChannelNoAvailableKey, http.StatusUnauthorized)
ProcessChannelError(c, types.ChannelError{ChannelId: channel.Id, ChannelName: channel.Name, AutoBan: true}, apiErr, nil)
var notification WebhookPayload
select {
case payload := <-notifications:
require.NoError(t, common.Unmarshal(payload, &notification))
case <-time.After(5 * time.Second):
t.Fatal("automatic channel-disable notification was not delivered")
}
loaded, err := model.GetChannelById(channel.Id, true)
require.NoError(t, err)
assert.Equal(t, common.ChannelStatusAutoDisabled, loaded.Status)
wantReason := "status_code=401, upstream https://***.com/***?token=*** api_key:***"
assert.Equal(t, wantReason, loaded.GetOtherInfo()["status_reason"])
assert.Contains(t, notification.Content, wantReason)
assert.NotContains(t, notification.Content, "review-token")
assert.NotContains(t, notification.Content, "review-secret")
assert.Equal(t, http.StatusUnauthorized, apiErr.StatusCode)
}
func TestDecideRelayRetryReasons(t *testing.T) {
upstream := func(status int) *types.NewAPIError {
return types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, status)
}
for _, tc := range []struct {
name string
err *types.NewAPIError
retries int
setup func(*gin.Context)
want PolicyDecision
}{
{name: "retry status matched", err: upstream(http.StatusTooManyRequests), retries: 1, want: PolicyDecision{Action: "retry", Reason: "retry_status_matched", Source: "global"}},
{name: "status outside retry rules", err: upstream(http.StatusBadRequest), retries: 1, want: PolicyDecision{Action: "stop", Reason: "status_not_retryable", Source: "global"}},
{name: "attempt budget exhausted", err: upstream(http.StatusTooManyRequests), retries: 0, want: PolicyDecision{Action: "stop", Reason: "attempt_budget_exhausted", Source: "global"}},
{name: "always skipped status", err: upstream(http.StatusGatewayTimeout), retries: 1, want: PolicyDecision{Action: "stop", Reason: "system_retry_exclusion", Source: "system"}},
{name: "success status never retries", err: upstream(http.StatusOK), retries: 1, want: PolicyDecision{Action: "stop", Reason: "system_retry_exclusion", Source: "system"}},
{name: "skip retry error", err: types.NewErrorWithStatusCode(errors.New("local"), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()), retries: 1, want: PolicyDecision{Action: "stop", Reason: "non_retryable_error", Source: "system"}},
{name: "channel error retries without budget", err: types.NewError(errors.New("no key"), types.ErrorCodeChannelNoAvailableKey), retries: 0, want: PolicyDecision{Action: "retry", Reason: "channel_error", Source: "system"}},
{name: "single attempt pin", err: upstream(http.StatusTooManyRequests), retries: 1, setup: func(c *gin.Context) {
GetChannelConstraints(c).AddPin(dto.ChannelPin{ChannelId: 1, Source: dto.PinSourceToken, Rank: dto.PinRankToken, RetryMode: dto.PinRetrySingleAttempt})
}, want: PolicyDecision{Action: "stop", Reason: "pinned_channel", Source: "channel_constraint"}},
{name: "strict session", err: upstream(http.StatusTooManyRequests), retries: 1, setup: func(c *gin.Context) {
c.Set(ginKeyChannelAffinitySkipRetry, true)
RequestPolicy(c).SessionModeSource = "global"
}, want: PolicyDecision{Action: "stop", Reason: "strict_session", Source: "global"}},
{name: "nil error", retries: 1, want: PolicyDecision{Action: "stop", Reason: "request_completed", Source: "system"}},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
if tc.setup != nil {
tc.setup(c)
}
decision := DecideRelayRetry(c, tc.err, tc.retries)
assert.Equal(t, tc.want, decision)
assert.Equal(t, tc.want.Action == "retry", ShouldRetryRelayError(c, tc.err, tc.retries))
})
}
}
func TestRequestPolicyEventsReachLogAdminInfo(t *testing.T) {
previousAutoDisable := common.AutomaticDisableChannelEnabled
common.AutomaticDisableChannelEnabled = true
t.Cleanup(func() { common.AutomaticDisableChannelEnabled = previousAutoDisable })
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
c.Set("auto_ban", true)
c.Set("channel_id", 7)
state := RequestPolicy(c)
state.BeginAttempt(&model.Channel{Id: 7}, "default")
apiErr := types.NewOpenAIError(errors.New("invalid credential"), types.ErrorCodeBadResponseStatusCode, http.StatusUnauthorized)
RecordPolicyFailure(c, 7, apiErr, DecideRelayRetry(c, apiErr, 0))
failed := model.NewLogOther()
AppendRelayLogAdminInfo(c, nil, failed)
events, ok := failed.Snapshot()["admin_info"].(map[string]any)["request_policy"].([]PolicyEvent)
require.True(t, ok, "a failed relay exposes its decision events to administrators")
require.Len(t, events, 3)
assert.Equal(t, PolicyDecision{Action: "attempt", Reason: "channel_selected", Source: "routing"}, events[0].Decision)
assert.Equal(t, "default", events[0].Group)
assert.Equal(t, PolicyDecision{Action: "failure", Reason: "upstream_failure", Source: "upstream"}, events[1].Decision)
assert.Equal(t, http.StatusUnauthorized, events[1].Status)
assert.Equal(t, PolicyDecision{Action: "stop", Reason: "attempt_budget_exhausted", Source: "global"}, events[2].Decision)
assert.Equal(t, "channel_disable_requested", events[2].Health, "the health entry follows the automatic disable rules")
common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true)
RecordPolicyFailure(c, 7, apiErr, DecideRelayRetry(c, apiErr, 0))
assert.Equal(t, "key_disable_requested", state.Events()[4].Health)
state.BeginAttempt(&model.Channel{Id: 8}, "default")
c.Set("channel_id", 8)
MarkRequestPolicySuccess(c, nil)
MarkRequestPolicySuccess(c, nil)
succeeded := model.NewLogOther()
AppendRelayLogAdminInfo(c, nil, succeeded)
events, ok = succeeded.Snapshot()["admin_info"].(map[string]any)["request_policy"].([]PolicyEvent)
require.True(t, ok, "a successful relay exposes its decision events to administrators")
require.Len(t, events, 7, "the outcome is recorded once")
assert.Equal(t, PolicyDecision{Action: "success", Reason: "request_completed", Source: "upstream"}, events[6].Decision)
assert.Equal(t, 8, events[6].ChannelID)
assert.Equal(t, 2, events[6].Attempt)
assert.True(t, state.Successful)
untouched, _ := gin.CreateTestContext(httptest.NewRecorder())
other := model.NewLogOther()
AppendRelayLogAdminInfo(untouched, nil, other)
assert.NotContains(t, other.Snapshot()["admin_info"], "request_policy", "requests without decisions do not carry an empty record")
}
func TestRelayErrorGroup(t *testing.T) {
for _, tc := range []struct {
name string
channel int
actual, auto, selected, base, want string
}{
{"actual group", 102, "example-preferred", "stale", "stale", "default", "example-preferred"},
{"auto group", 102, "", "example-preferred", "", "default", "example-preferred"},
{"pool selection", 102, "", "", "example-preferred", "default", "example-preferred"},
{"fixed group", 27, "", "", "", "fixed", "fixed"},
{"no selection", 0, "stale", "stale", "stale", "default", ""},
{"unresolved auto", 27, "", "", "", "auto", ""},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(c, constant.ContextKeyUsingGroup, tc.base)
common.SetContextKey(c, constant.ContextKeyAutoGroup, tc.auto)
RequestPolicy(c).SelectedGroup = tc.selected
var info *relaycommon.RelayInfo
if tc.actual != "" {
info = &relaycommon.RelayInfo{UsingGroup: tc.actual}
}
require.Equal(t, tc.want, relayErrorGroup(c, tc.channel, info))
})
}
}
+115
View File
@@ -0,0 +1,115 @@
package service
import (
"crypto/sha256"
"fmt"
"strings"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
)
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"`
}
func appendRequestDetails(c *gin.Context, info *relaycommon.RelayInfo, other *model.LogOther) {
d := RequestDetails{SessionSource: "none", RequestedModel: c.GetString("original_model"), Status: c.Writer.Status()}
if status, ok := other.Snapshot()["status_code"].(int); ok {
d.Status = status
}
if c.Request != nil {
for _, h := range []struct{ name, source string }{{"session_id", "session"}, {"Session-Id", "session"}} {
if value := strings.TrimSpace(c.GetHeader(h.name)); value != "" {
d.SessionID, d.SessionSource = value, h.source
break
}
}
}
if d.SessionID == "" {
if value := strings.TrimSpace(c.GetString("request_session_id")); value != "" {
d.SessionID = value
d.SessionSource = "session_body"
}
}
if d.SessionID == "" && c.Request != nil {
for _, key := range []string{"thread_id", "Thread-Id"} {
if value := strings.TrimSpace(c.GetHeader(key)); value != "" {
d.SessionID = value
d.SessionSource = "thread"
break
}
}
}
if d.SessionID == "" {
if value := strings.TrimSpace(c.GetString("request_thread_id")); value != "" {
d.SessionID = value
d.SessionSource = "thread_body"
}
}
if d.SessionID == "" {
if meta, ok := getChannelAffinityMeta(c); ok && meta.KeyFingerprint != "" {
d.SessionID = meta.KeyFingerprint
d.SessionSource = "affinity"
}
}
if d.SessionID != "" {
d.CorrelationKey = fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s\x00%s", c.GetInt("id"), d.SessionSource, d.SessionID))))
// Hash the complete identifier before limiting its display size.
if len(d.SessionID) > 256 {
d.SessionID = string([]rune(d.SessionID)[:min(256, len([]rune(d.SessionID)))]) + "…"
}
}
c.Set("log_correlation_key", d.CorrelationKey)
if info != nil {
d.ModelGuard = info.ResponseGuard
d.RequestedModel = info.OriginModelName
d.UpstreamModel = info.GetUpstreamModelName()
d.ReasoningEffort = info.ReasoningEffort
if info.ResponseModel != nil {
d.ReturnedModel = info.ResponseModel.ReturnedModel
}
}
other.SetAdmin("request_details", d)
}
func recordSelection(c *gin.Context, group string, trace *model.SelectionTrace, reason string) {
state := RequestPolicy(c)
remaining := max(0, 100-state.candidateCount)
items := trace.Candidates[:min(remaining, len(trace.Candidates))]
state.candidateCount += len(items)
channelID := trace.SelectedID
for _, item := range trace.Candidates {
if item.Reason == "selected" || item.Reason == "affinity_hit" || item.Reason == "pinned_channel" {
channelID = item.ChannelID
break
}
}
state.AddEvent(PolicyEvent{Attempt: state.Attempts + 1, ChannelID: channelID, Group: group, Candidates: items, CandidateTotal: trace.Total, Decision: PolicyDecision{Action: "selection", Reason: reason, Source: "routing"}})
}
func selectWithTrace(param *RetryParam, group string, retry int) (*model.Channel, error) {
trace := &model.SelectionTrace{}
channel, err := model.GetRandomSatisfiedChannel(group, param.ModelName, retry, GetChannelConstraints(param.Ctx).Filters, trace)
reason := "priority_weight_selection"
if channel == nil {
reason = "no_available_channel"
}
recordSelection(param.Ctx, group, trace, reason)
return channel, err
}
func recordDirectSelection(c *gin.Context, ch *model.Channel, id int, group, modelName, reason string) {
trace := &model.SelectionTrace{}
trace.Add(ch, id, group, modelName, reason)
recordSelection(c, group, trace, reason)
}
+194
View File
@@ -0,0 +1,194 @@
package service
import (
"slices"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
const requestPolicyContextKey = "request_policy_state"
type PolicyDecision struct {
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source"`
}
type PolicyEvent struct {
Sequence int `json:"sequence,omitempty"`
Candidates []model.SelectionCandidate `json:"candidates,omitempty"`
CandidateTotal int `json:"candidate_total,omitempty"`
Attempt int `json:"attempt"`
ChannelID int `json:"channel_id,omitempty"`
Group string `json:"group,omitempty"`
Rule string `json:"rule,omitempty"`
Status int `json:"status,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
ErrorSource string `json:"error_source,omitempty"`
ElapsedMS int64 `json:"elapsed_ms"`
Decision PolicyDecision `json:"decision"`
Health string `json:"health,omitempty"`
}
// RequestPolicyState records how one request was routed so administrators can
// read the decision flow in the log details. Channel selection and the retry
// decision stay in the relay flows; this state only records them.
type RequestPolicyState struct {
FinalLogged bool
StartedAt time.Time
Attempts int
SelectedGroup string
SessionMode string
SessionModeSource string
RuleName string
Successful bool
OutcomeRecorded bool
mu sync.Mutex
events []PolicyEvent
candidateCount int
}
func RequestPolicy(c *gin.Context) *RequestPolicyState {
if c != nil {
if value, ok := c.Get(requestPolicyContextKey); ok {
return value.(*RequestPolicyState)
}
}
state := &RequestPolicyState{StartedAt: time.Now()}
if c != nil {
c.Set(requestPolicyContextKey, state)
}
return state
}
func (s *RequestPolicyState) AddEvent(event PolicyEvent) {
if event.Attempt == 0 {
event.Attempt = s.Attempts
}
event.ElapsedMS = time.Since(s.StartedAt).Milliseconds()
if event.Group == "" {
event.Group = s.SelectedGroup
}
if event.Rule == "" {
event.Rule = s.RuleName
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.events) < 512 {
event.Sequence = len(s.events) + 1
s.events = append(s.events, event)
}
}
func (s *RequestPolicyState) Events() []PolicyEvent {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.events)
}
func (s *RequestPolicyState) BeginAttempt(channel *model.Channel, group string) {
s.candidateCount = 0
if s.SelectedGroup != "" && group != "" && group != s.SelectedGroup {
s.AddEvent(PolicyEvent{Attempt: s.Attempts + 1, Group: group, Decision: PolicyDecision{Action: "switch", Reason: "fallback_group_switch", Source: "routing"}})
}
s.Attempts++
s.Successful = false
s.OutcomeRecorded = false
s.SelectedGroup = group
s.AddEvent(PolicyEvent{ChannelID: channel.Id, Decision: PolicyDecision{Action: "attempt", Reason: "channel_selected", Source: "routing"}})
}
// RecordPolicyFailure appends the failed attempt and the retry decision made
// for it. The health entry mirrors the check in ProcessChannelError, which
// performs the actual disable.
func RecordPolicyFailure(c *gin.Context, channelID int, err *types.NewAPIError, decision PolicyDecision) {
if c == nil || err == nil {
return
}
source := "upstream"
switch {
case types.IsChannelError(err):
source = "channel"
case err.GetErrorCode() == types.ErrorCodeDoRequestFailed:
source = "transport"
case err.GetErrorType() == types.ErrorTypeNewAPIError:
source = "local"
}
state := RequestPolicy(c)
event := PolicyEvent{ChannelID: channelID, Status: err.StatusCode, ErrorCode: string(err.GetErrorCode()), ErrorSource: source, Decision: PolicyDecision{Action: "failure", Reason: "upstream_failure", Source: source}}
if source == "local" {
event.Decision.Reason = "local_rejection"
}
state.AddEvent(event)
event.Decision, event.Health = decision, "unchanged"
if err.GetErrorCode() != relaycommon.ResponseGuardErrorCode && source != "local" && c.GetBool("auto_ban") && ShouldDisableChannel(err) {
event.Health = "channel_disable_requested"
if common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) {
event.Health = "key_disable_requested"
}
}
state.AddEvent(event)
}
func MarkRequestPolicySuccess(c *gin.Context, stream *relaycommon.StreamStatus) {
state := RequestPolicy(c)
if state.OutcomeRecorded {
return
}
state.OutcomeRecorded = true
state.Successful = stream == nil || stream.IsNormalEnd() && !stream.HasErrors() && (stream.ResponseOutcome() == "" || stream.ResponseOutcome() == "completed")
decision := PolicyDecision{Action: "success", Reason: "request_completed", Source: "upstream"}
if !state.Successful {
decision = PolicyDecision{Action: "stop", Reason: "stream_not_successful", Source: "system"}
}
channelID := 0
if c != nil {
channelID = c.GetInt("channel_id")
}
state.AddEvent(PolicyEvent{ChannelID: channelID, Decision: decision})
}
// Rules explicitly inherit the global default or override it. Rules without a
// mode retain their legacy retry behavior until an administrator changes them.
func EffectiveSessionMode(setting *operation_setting.ChannelAffinitySetting, rule operation_setting.ChannelAffinityRule) (mode, source string) {
if rule.SessionMode == "inherit" {
if setting.SessionMode != "" {
return setting.SessionMode, "global"
}
return "prefer", "global"
}
if rule.SessionMode != "" {
return rule.SessionMode, "session_rule"
}
if rule.SkipRetryOnFailure {
return "strict", "session_rule"
}
return "prefer", "session_rule"
}
// RecordRequestPolicyTermination appends the final decision after routing has
// stopped. It never writes a log row itself: the per-channel error log written
// by ProcessChannelError already carries the decision record, so a second row
// here would duplicate it.
func RecordRequestPolicyTermination(c *gin.Context, apiErr *types.NewAPIError) {
if c == nil || apiErr == nil {
return
}
state := RequestPolicy(c)
if state.FinalLogged {
return
}
state.FinalLogged = true
events := state.Events()
if len(events) == 0 || events[len(events)-1].Decision.Action != "stop" {
state.AddEvent(PolicyEvent{ChannelID: c.GetInt("channel_id"), Status: apiErr.StatusCode, ErrorCode: string(apiErr.GetErrorCode()), Decision: PolicyDecision{Action: "stop", Reason: "request_failed", Source: "system"}, Health: "unchanged"})
}
}
+170
View File
@@ -0,0 +1,170 @@
package service
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
)
// ResponsesUsageAccumulator owns the accounting facts for one Responses stream.
// HTTP SSE and WebSocket transports feed the same events into it, then settle
// Finish's usage through the normal text billing path, including interrupted
// streams. Observe and Finish must be called by the same stream owner.
type ResponsesUsageAccumulator struct {
info *relaycommon.RelayInfo
usage *dto.Usage
outputText strings.Builder
imageCounter relaycommon.ImageGenerationCallCounter
imageCommitted bool
started bool
hasUsage bool
finished bool
}
func NewResponsesUsageAccumulator(info *relaycommon.RelayInfo) *ResponsesUsageAccumulator {
return &ResponsesUsageAccumulator{info: info, usage: &dto.Usage{}}
}
func (a *ResponsesUsageAccumulator) Observe(event *dto.ResponsesStreamResponse) {
if a == nil || event == nil || a.finished {
return
}
if event.Response != nil {
a.info.ObserveResponseModel(event.Response.Model)
}
a.started = true
ObserveResponsesOutcome(a.info, event)
switch event.Type {
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 a.outputText.Len() == 0 {
// Some upstreams carry the output only on the terminal event.
a.outputText.WriteString(relayconvert.ExtractOutputTextFromResponses(event.Response))
}
}
if a.imageCommitted {
return
}
failed := event.Type != "response.completed" && event.Type != "response.done"
if failed || (event.Response != nil && relaycommon.IsNonBillableResponsesStatus(event.Response.Status)) {
a.imageCounter.Reset()
} else if event.Response != nil {
for i := range event.Response.Output {
a.imageCounter.Observe(&event.Response.Output[i], &i)
}
}
a.imageCounter.Commit(a.info)
a.imageCommitted = true
case "response.output_text.delta", "response.function_call_arguments.delta",
"response.reasoning_summary_text.delta", "response.reasoning_text.delta", "response.refusal.delta":
// Every delta kind here is generated output that upstream bills as
// output tokens, so all of them feed the missing-usage estimate.
a.outputText.WriteString(event.Delta)
case dto.ResponsesOutputTypeItemDone:
if event.Item == nil {
return
}
switch event.Item.Type {
case dto.BuildInCallWebSearchCall, dto.BuildInCallFileSearchCall, dto.BuildInCallFunctionCall:
a.info.CountBillableToolCall(event.Item.Type, event.Item.Name)
case dto.ResponsesOutputTypeImageGenerationCall:
if !a.imageCommitted {
a.imageCounter.Observe(event.Item, event.OutputIndex)
}
}
}
}
func (a *ResponsesUsageAccumulator) Finish() *dto.Usage {
if a.finished {
return a.usage
}
a.finished = true
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.
if !a.imageCommitted {
a.imageCounter.Commit(a.info)
a.imageCommitted = true
}
if a.usage.CompletionTokens == 0 {
if output := a.outputText.String(); output != "" {
a.usage.CompletionTokens = CountTextToken(output, a.info.GetUpstreamModelName())
}
}
// Upstream bills the prompt as soon as it starts generating, so a stream
// that produced any event but no usage still owes its input tokens unless
// upstream reported an explicit failure.
billsPrompt := a.usage.CompletionTokens != 0 || (a.started && !a.info.StreamStatus.ResponseFailed())
if a.usage.PromptTokens == 0 && billsPrompt {
a.usage.PromptTokens = a.info.GetEstimatePromptTokens()
}
a.usage.TotalTokens = a.usage.PromptTokens + a.usage.CompletionTokens
if a.usage.BillingUsage != nil {
a.usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(a.usage.BillingUsage, a.usage.CompletionTokens)
}
return a.usage
}
// ObserveResponsesOutcome records the protocol outcome of one Responses event
// on the stream status for health classification. Only codes and types are
// kept; messages never leave the event.
func ObserveResponsesOutcome(info *relaycommon.RelayInfo, event *dto.ResponsesStreamResponse) {
if info == nil || info.StreamStatus == nil || event == nil {
return
}
var responseStatus string
if event.Response != nil {
_ = common.Unmarshal(event.Response.Status, &responseStatus)
}
switch {
case event.Type == "error" || event.Type == "response.failed" || event.Type == "response.error" || responseStatus == "failed":
code, errorType := event.Code, ""
if event.Response != nil {
if oaiErr := event.Response.GetOpenAIError(); oaiErr != nil {
if oaiErr.Code != nil {
code = fmt.Sprint(oaiErr.Code)
}
errorType = oaiErr.Type
}
}
info.StreamStatus.MarkFailed(code, errorType, 0)
case event.Type == "response.incomplete" || responseStatus == "incomplete":
reason := ""
if event.Response != nil && event.Response.IncompleteDetails != nil {
reason = event.Response.IncompleteDetails.Reason
}
info.StreamStatus.MarkIncomplete(reason)
case event.Type == "response.cancelled" || event.Type == "response.canceled" || responseStatus == "cancelled":
info.StreamStatus.MarkCancelled()
case event.Type == "response.completed" || event.Type == "response.done" || responseStatus == "completed":
info.StreamStatus.MarkCompleted()
}
}
func ApplyResponsesUsage(dst *dto.Usage, src *dto.Usage) {
if dst == nil || src == nil {
return
}
incoming := relayconvert.NormalizeResponsesUsage(src)
if src.InputTokensDetails != nil {
inputDetails := *src.InputTokensDetails
incoming.InputTokensDetails = &inputDetails
}
if src.OutputTokensDetails != nil {
incoming.CompletionTokenDetails = *src.OutputTokensDetails
}
incoming.PromptCacheHitTokens = src.PromptCacheHitTokens
dto.MergeUsageNonZero(dst, incoming)
outputDetails := dst.CompletionTokenDetails
if outputDetails != (dto.OutputTokenDetails{}) {
dst.OutputTokensDetails = &outputDetails
}
}
+341
View File
@@ -0,0 +1,341 @@
package service
import (
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesUsageAccumulatorTerminalAccounting(t *testing.T) {
operation_setting.SetToolPriceForTest("responses_priced_fn", 5)
t.Cleanup(func() { operation_setting.DeleteToolPriceForTest("responses_priced_fn") })
for _, tc := range []struct {
eventType string
wantImages int
}{
{eventType: "response.completed", wantImages: 1},
{eventType: "response.done", wantImages: 1},
{eventType: "response.incomplete"},
{eventType: "response.failed"},
{eventType: "response.cancelled"},
{eventType: "response.canceled"},
} {
t.Run(tc.eventType, func(t *testing.T) {
info := &relaycommon.RelayInfo{OriginModelName: "gpt-5.1", StreamStatus: relaycommon.NewStreamStatus()}
accumulator := NewResponsesUsageAccumulator(info)
for _, item := range []dto.ResponsesOutput{
{Type: dto.BuildInCallWebSearchCall},
{Type: dto.BuildInCallFileSearchCall},
{Type: dto.BuildInCallFunctionCall, Name: "responses_priced_fn"},
{Type: dto.BuildInCallFunctionCall, Name: "responses_unpriced_fn"},
} {
accumulator.Observe(&dto.ResponsesStreamResponse{Type: dto.ResponsesOutputTypeItemDone, Item: &item})
}
image := dto.ResponsesOutput{ID: "image-1", Type: dto.ResponsesOutputTypeImageGenerationCall, Status: "completed", Result: "image-data"}
accumulator.Observe(&dto.ResponsesStreamResponse{Type: dto.ResponsesOutputTypeItemDone, Item: &image})
upstream := &dto.Usage{InputTokens: 20, OutputTokens: 5, TotalTokens: 25, InputTokensDetails: &dto.InputTokenDetails{CachedTokens: 4}}
upstream.BillingUsage = dto.NewOpenAIResponsesBillingUsage(upstream)
terminal := &dto.ResponsesStreamResponse{
Type: tc.eventType,
Response: &dto.OpenAIResponsesResponse{
Usage: upstream, Output: []dto.ResponsesOutput{image},
},
}
accumulator.Observe(terminal)
accumulator.Observe(terminal)
usage := accumulator.Finish()
assert.Equal(t, tc.eventType == "response.failed", info.StreamStatus.ResponseFailed())
assert.NotEmpty(t, info.StreamStatus.ResponseOutcome())
assert.Equal(t, 20, usage.PromptTokens)
assert.Equal(t, 5, usage.CompletionTokens)
assert.Equal(t, 25, usage.TotalTokens)
assert.Equal(t, 4, usage.PromptTokensDetails.CachedTokens)
require.NotNil(t, usage.BillingUsage)
assert.Equal(t, upstream.BillingUsage, usage.BillingUsage)
assert.NotSame(t, upstream.BillingUsage, usage.BillingUsage)
tools := info.ResponsesUsageInfo.BuiltInTools
for _, name := range []string{dto.BuildInToolWebSearchPreview, dto.BuildInToolFileSearch, "responses_priced_fn"} {
require.Contains(t, tools, name)
assert.Equal(t, 1, tools[name].CallCount)
}
assert.NotContains(t, tools, "responses_unpriced_fn")
require.Contains(t, tools, dto.BuildInToolImageGeneration)
assert.Equal(t, tc.wantImages, tools[dto.BuildInToolImageGeneration].CallCount)
})
}
}
func TestResponsesUsageAccumulatorInterruptedTextFallback(t *testing.T) {
for _, withUsage := range []bool{false, true} {
name := "disconnect without terminal usage"
if withUsage {
name = "failed response preserves native billing usage"
}
t.Run(name, func(t *testing.T) {
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-4o"}}
info.SetEstimatePromptTokens(100)
accumulator := NewResponsesUsageAccumulator(info)
accumulator.Observe(&dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "hello"})
if withUsage {
upstream := &dto.Usage{InputTokens: 20, InputTokensDetails: &dto.InputTokenDetails{CachedTokens: 4}}
upstream.BillingUsage = dto.NewOpenAIResponsesBillingUsage(upstream)
accumulator.Observe(&dto.ResponsesStreamResponse{Type: "response.failed", Response: &dto.OpenAIResponsesResponse{Usage: upstream}})
}
usage := accumulator.Finish()
assert.Equal(t, 1, usage.CompletionTokens)
if withUsage {
assert.Equal(t, 20, usage.PromptTokens)
assert.Equal(t, 21, usage.TotalTokens)
require.NotNil(t, usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceOAIResponses, usage.BillingUsage.Source)
assert.True(t, usage.BillingUsage.Estimated)
canonical, ok := usage.BillingUsage.CanonicalUsage()
require.True(t, ok)
assert.Equal(t, 4, canonical.PromptTokensDetails.CachedTokens)
assert.Equal(t, 1, canonical.CompletionTokens)
} else {
assert.Equal(t, 100, usage.PromptTokens)
assert.Equal(t, 101, usage.TotalTokens)
}
accumulator.Observe(&dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: " late output"})
assert.Equal(t, usage, accumulator.Finish())
assert.Equal(t, 1, usage.CompletionTokens)
})
}
}
func TestObserveResponsesOutcomeRecordsProtocolFacts(t *testing.T) {
for _, tc := range []struct {
name string
event string
wantOutcome relaycommon.ResponseOutcome
wantCode string
wantType string
wantIncompl string
}{
{"flat sse error", `{"type":"error","code":"context_length_exceeded","message":"too long"}`, relaycommon.ResponseOutcomeFailed, "context_length_exceeded", "", ""},
{"done with failed status", `{"type":"response.done","response":{"status":"failed","error":{"code":"invalid_api_key","type":"invalid_request_error","message":"bad key"}}}`, relaycommon.ResponseOutcomeFailed, "invalid_api_key", "invalid_request_error", ""},
{"incomplete keeps reason", `{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`, relaycommon.ResponseOutcomeIncomplete, "", "", "max_output_tokens"},
{"in progress is not terminal", `{"type":"response.created","response":{"status":"in_progress"}}`, relaycommon.ResponseOutcomeUnknown, "", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
var event dto.ResponsesStreamResponse
require.NoError(t, common.UnmarshalJsonStr(tc.event, &event))
info := &relaycommon.RelayInfo{StreamStatus: relaycommon.NewStreamStatus()}
ObserveResponsesOutcome(info, &event)
outcome := info.StreamStatus.OutcomeSnapshot()
assert.Equal(t, tc.wantOutcome, outcome.Response)
assert.Equal(t, tc.wantCode, outcome.ErrorCode)
assert.Equal(t, tc.wantType, outcome.ErrorType)
assert.Equal(t, tc.wantIncompl, outcome.IncompleteReason)
})
}
}
func TestResponsesUsageAccumulatorDisconnectBillsCompletedImage(t *testing.T) {
info := &relaycommon.RelayInfo{OriginModelName: "gpt-5.1", StreamStatus: relaycommon.NewStreamStatus()}
accumulator := NewResponsesUsageAccumulator(info)
accumulator.Observe(&dto.ResponsesStreamResponse{
Type: dto.ResponsesOutputTypeItemDone,
Item: &dto.ResponsesOutput{ID: "complete-image", Type: dto.ResponsesOutputTypeImageGenerationCall, Status: "completed", Result: "final-image-data"},
})
accumulator.Observe(&dto.ResponsesStreamResponse{
Type: dto.ResponsesOutputTypeItemDone,
Item: &dto.ResponsesOutput{ID: "partial-image", Type: dto.ResponsesOutputTypeImageGenerationCall, Status: "partial", Result: "partial-image-data"},
})
usage := accumulator.Finish()
assert.Zero(t, usage.TotalTokens)
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolImageGeneration)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
accumulator.Finish()
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestApplyResponsesUsageCopiesTokenDetails(t *testing.T) {
dst := &dto.Usage{}
src := &dto.Usage{
InputTokens: 11,
OutputTokens: 7,
TotalTokens: 18,
InputTokensDetails: &dto.InputTokenDetails{
CachedTokens: 3,
CachedCreationTokens: 2,
TextTokens: 6,
AudioTokens: 4,
ImageTokens: 5,
},
OutputTokensDetails: &dto.OutputTokenDetails{
TextTokens: 1,
AudioTokens: 2,
ImageTokens: 3,
ReasoningTokens: 4,
},
PromptCacheHitTokens: 3,
UsageSemantic: "openai",
UsageSource: "upstream",
}
ApplyResponsesUsage(dst, src)
assert.Equal(t, 11, dst.PromptTokens)
assert.Equal(t, 7, dst.CompletionTokens)
assert.Equal(t, 18, dst.TotalTokens)
require.NotNil(t, dst.InputTokensDetails)
assert.Equal(t, *src.InputTokensDetails, dst.PromptTokensDetails)
assert.Equal(t, src.InputTokensDetails, dst.InputTokensDetails)
assert.NotSame(t, src.InputTokensDetails, dst.InputTokensDetails)
assert.Equal(t, *src.OutputTokensDetails, dst.CompletionTokenDetails)
require.NotNil(t, dst.OutputTokensDetails)
assert.Equal(t, *src.OutputTokensDetails, *dst.OutputTokensDetails)
assert.NotSame(t, src.OutputTokensDetails, dst.OutputTokensDetails)
assert.Equal(t, 3, dst.PromptCacheHitTokens)
assert.Equal(t, "openai", dst.UsageSemantic)
assert.Equal(t, "upstream", dst.UsageSource)
}
func TestApplyResponsesUsageFallsBackToCompletionTokenDetails(t *testing.T) {
dst := &dto.Usage{}
src := &dto.Usage{
CompletionTokenDetails: dto.OutputTokenDetails{
ReasoningTokens: 9,
},
}
ApplyResponsesUsage(dst, src)
assert.Equal(t, 9, dst.CompletionTokenDetails.ReasoningTokens)
require.NotNil(t, dst.OutputTokensDetails)
assert.Equal(t, 9, dst.OutputTokensDetails.ReasoningTokens)
}
func TestApplyResponsesUsagePreservesBillingSnapshotAcrossPartialUpdates(t *testing.T) {
dst := &dto.Usage{}
src := &dto.Usage{
InputTokens: 20,
OutputTokens: 10,
TotalTokens: 30,
InputTokensDetails: &dto.InputTokenDetails{
CachedTokens: 3,
AudioTokens: 2,
},
CompletionTokenDetails: dto.OutputTokenDetails{AudioTokens: 4},
UsageSemantic: "openai",
UsageSource: "upstream",
}
src.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
ApplyResponsesUsage(dst, src)
ApplyResponsesUsage(dst, &dto.Usage{
OutputTokens: 12,
InputTokensDetails: &dto.InputTokenDetails{},
OutputTokensDetails: &dto.OutputTokenDetails{ReasoningTokens: 5},
})
assert.Equal(t, 20, dst.PromptTokens)
assert.Equal(t, 12, dst.CompletionTokens)
assert.Equal(t, 32, dst.TotalTokens)
assert.Equal(t, 3, dst.PromptTokensDetails.CachedTokens)
assert.Equal(t, 2, dst.PromptTokensDetails.AudioTokens)
assert.Equal(t, 4, dst.CompletionTokenDetails.AudioTokens)
assert.Equal(t, 5, dst.CompletionTokenDetails.ReasoningTokens)
require.NotNil(t, dst.OutputTokensDetails)
assert.Equal(t, dst.CompletionTokenDetails, *dst.OutputTokensDetails)
assert.Equal(t, "openai", dst.UsageSemantic)
assert.Equal(t, "upstream", dst.UsageSource)
require.NotNil(t, dst.BillingUsage)
assert.Equal(t, src.BillingUsage, dst.BillingUsage)
assert.NotSame(t, src.BillingUsage, dst.BillingUsage)
}
func TestResponsesUsageAccumulatorMissingUsageEstimation(t *testing.T) {
const model = "gpt-4o"
const summary = "Inspect the repository before editing."
const arguments = `{"command":["bash","-lc","ls"]}`
inProgress := &dto.OpenAIResponsesResponse{Status: []byte(`"in_progress"`)}
for _, tc := range []struct {
name string
events []dto.ResponsesStreamResponse
wantPrompt int
wantCompletion int
}{
{
name: "tool call stream cut before terminal usage",
events: []dto.ResponsesStreamResponse{
{Type: "response.created", Response: inProgress},
{Type: "response.reasoning_summary_text.delta", Delta: summary},
{Type: "response.function_call_arguments.delta", Delta: arguments},
{Type: dto.ResponsesOutputTypeItemDone, Item: &dto.ResponsesOutput{Type: dto.BuildInCallFunctionCall, Name: "shell"}},
},
wantPrompt: 100,
wantCompletion: CountTextToken(summary+arguments, model),
},
{
name: "created only then disconnect bills the prompt",
events: []dto.ResponsesStreamResponse{
{Type: "response.created", Response: inProgress},
},
wantPrompt: 100,
},
{
name: "incomplete without usage bills the prompt",
events: []dto.ResponsesStreamResponse{
{Type: "response.created", Response: inProgress},
{Type: "response.incomplete", Response: &dto.OpenAIResponsesResponse{Status: []byte(`"incomplete"`)}},
},
wantPrompt: 100,
},
{
name: "completed without usage estimates from terminal output",
events: []dto.ResponsesStreamResponse{
{Type: "response.completed", Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{{
Type: "message",
Role: "assistant",
Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "final answer"}},
}},
}},
},
wantPrompt: 100,
wantCompletion: CountTextToken("final answer", model),
},
{
name: "explicit failure without usage bills nothing",
events: []dto.ResponsesStreamResponse{
{Type: "response.created", Response: inProgress},
{Type: "response.failed", Response: &dto.OpenAIResponsesResponse{Status: []byte(`"failed"`)}},
},
},
{
name: "flat error event bills nothing",
events: []dto.ResponsesStreamResponse{
{Type: "response.created", Response: inProgress},
{Type: "error", Code: "server_error"},
},
},
{
name: "no upstream events bills nothing",
},
} {
t.Run(tc.name, func(t *testing.T) {
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model},
StreamStatus: relaycommon.NewStreamStatus(),
}
info.SetEstimatePromptTokens(100)
accumulator := NewResponsesUsageAccumulator(info)
for i := range tc.events {
accumulator.Observe(&tc.events[i])
}
usage := accumulator.Finish()
assert.Equal(t, tc.wantPrompt, usage.PromptTokens)
assert.Equal(t, tc.wantCompletion, usage.CompletionTokens)
assert.Equal(t, tc.wantPrompt+tc.wantCompletion, usage.TotalTokens)
})
}
}
+1 -1
View File
@@ -212,7 +212,7 @@ func securityVerificationPolicy(scope string, state model.UserVerificationState)
if method == VerificationMethodTwoFA && state.TwoFALocked {
option.Available, option.Reason = false, ErrVerificationLocked.Error()
}
if !system_setting.GetPasskeySettings().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) {
if !system_setting.PasskeySettingsSnapshot().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) {
option.Available, option.Reason = false, "Passkey authentication is disabled."
}
options = append(options, option)
+17 -1
View File
@@ -284,10 +284,26 @@ func runSystemTaskScheduler() {
if latest.Status == model.SystemTaskStatusPending || latest.Status == model.SystemTaskStatusRunning {
continue // an active row already exists
}
if now-latest.UpdatedAt < int64(scheduled.Interval().Seconds()) {
anchor := latest.UpdatedAt
if anchored, ok := scheduled.(interface{ ScheduleFromCreation() bool }); ok && anchored.ScheduleFromCreation() {
anchor = latest.CreatedAt
}
if now-anchor < int64(scheduled.Interval().Seconds()) {
continue // not due yet
}
}
if gated, ok := scheduled.(interface {
ScheduleDue(context.Context, time.Time) (bool, error)
}); ok {
due, err := gated.ScheduleDue(context.Background(), time.Unix(now, 0))
if err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task due lookup failed: type=%s err=%v", scheduled.Type(), err))
continue
}
if !due {
continue
}
}
if _, err := model.CreateSystemTask(scheduled.Type(), scheduled.NewPayload(), nil); err != nil {
activeTask, activeErr := model.GetActiveSystemTask(scheduled.Type())
if activeErr == nil && activeTask != nil {
+20
View File
@@ -4,12 +4,15 @@ import (
"context"
"encoding/base64"
"fmt"
"maps"
"math"
"strings"
"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"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
@@ -379,3 +382,20 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
RecalculateTaskQuota(ctx, task, actualQuota, reason, clamp)
return true
}
// EvaluateTaskCompletionUsage evaluates actual facts against the frozen task
// expression. It neither mutates the snapshot nor moves funds: synchronous
// submission and polling have different persistence and settlement barriers.
func EvaluateTaskCompletionUsage(snap *billingexpr.BillingSnapshot, facts map[string]any) (billingexpr.TieredResult, map[string]any, error) {
if snap == nil {
return billingexpr.TieredResult{}, nil, fmt.Errorf("task billing snapshot is missing")
}
usage := make(map[string]any, len(snap.UsageFacts)+len(facts))
maps.Copy(usage, snap.UsageFacts)
maps.Copy(usage, facts)
result, err := billingexpr.ComputeTieredQuotaWithRequest(snap, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: usage})
if err == nil && (result.ActualQuotaBeforeGroup < 0 || math.IsNaN(result.ActualQuotaBeforeGroup)) {
err = fmt.Errorf("task completion expression produced an invalid cost")
}
return result, usage, err
}
+1 -6
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"io"
"maps"
"net/http"
"sort"
"strings"
@@ -16,7 +15,6 @@ import (
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -670,10 +668,7 @@ func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor
if task.Status == model.TaskStatusFailure {
return false
}
usageFacts := make(map[string]any, len(bc.TieredSnapshot.UsageFacts)+len(taskResult.UsageFacts))
maps.Copy(usageFacts, bc.TieredSnapshot.UsageFacts)
maps.Copy(usageFacts, taskResult.UsageFacts)
result, err := billingexpr.ComputeTieredQuotaWithRequest(bc.TieredSnapshot, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: usageFacts})
result, usageFacts, err := EvaluateTaskCompletionUsage(bc.TieredSnapshot, taskResult.UsageFacts)
if err != nil {
logger.LogWarn(ctx, fmt.Sprintf("任务 %s 表达式结算失败,保留预扣额度: %v", task.TaskID, err))
return true
+27 -13
View File
@@ -12,14 +12,12 @@ import (
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
@@ -66,6 +64,7 @@ type textQuotaSummary struct {
AudioInputPrice float64
ToolSurchargeItems []ToolSurchargeItem
ToolCallSurchargeQuota decimal.Decimal
FixedPriceBilling bool
}
// hasBillableUsage reports whether this request should incur any charge.
@@ -73,7 +72,7 @@ type textQuotaSummary struct {
// surcharge (e.g. /v1/alpha/search returns no usage but bills one web_search
// call), so token count alone is not sufficient to decide.
func (s *textQuotaSummary) hasBillableUsage() bool {
return s.TotalTokens > 0 || !s.ToolCallSurchargeQuota.IsZero()
return s.FixedPriceBilling || s.TotalTokens > 0 || !s.ToolCallSurchargeQuota.IsZero()
}
func cacheWriteTokensTotal(summary textQuotaSummary) int {
@@ -397,7 +396,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
if usage == nil {
extraContent = append(extraContent, "上游无计费信息")
}
if originUsage != nil {
if originUsage != nil && !ctx.GetBool("attempt_checkpoint") {
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, billingUsage, relayInfo.GetFinalRequestRelayFormat())
}
@@ -405,17 +404,30 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
summary := calculateTextQuotaSummary(ctx, relayInfo, billingUsage)
var tieredResult *billingexpr.TieredResult
var tieredTokens billingexpr.TokenParams
tieredBillingApplied := false
if originUsage != nil {
snap := relayInfo.TieredBillingSnapshot
// Providers normally estimate missing usage before settlement. Preserve the
// same prompt estimate when a fixed-price expression reaches us without it;
// its conditions must still run and may select a token-priced fallback.
if billingUsage == nil && snap != nil && billingexpr.UsesFixedPricingByHash(snap.ExprString, snap.ExprHash) {
billingUsage = &dto.Usage{PromptTokens: summary.PromptTokens, CompletionTokens: summary.CompletionTokens, TotalTokens: summary.TotalTokens}
}
if billingUsage != nil {
var tieredUsedVars map[string]bool
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
tieredUsedVars = billingexpr.UsedVars(snap.ExprString)
tieredUsedVars = billingexpr.UsedVarsByHash(snap.ExprString, snap.ExprHash)
}
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(billingUsage, summary.IsClaudeUsageSemantic, tieredUsedVars))
tieredTokens = BuildTieredTokenParams(billingUsage, summary.IsClaudeUsageSemantic, tieredUsedVars)
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, tieredTokens)
if tieredOk {
tieredBillingApplied = true
tieredResult = tieredRes
summary.Quota = composeTieredTextQuota(relayInfo, summary, tieredQuota, tieredRes)
summary.FixedPriceBilling = isFixedPriceSettlement(relayInfo, tieredRes)
if summary.FixedPriceBilling {
summary.AudioInputPrice = 0
}
}
}
@@ -440,14 +452,16 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
if !summary.hasBillableUsage() {
extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota))
} else {
} else if relayInfo.DurableAttemptID == 0 {
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, summary.Quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, summary.Quota)
}
if relayInfo.DurableAttemptID == 0 {
if err := SettleBilling(ctx, relayInfo, summary.Quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
}
logModel := summary.ModelName
if strings.HasPrefix(logModel, "gpt-4-gizmo") {
@@ -516,11 +530,12 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
}
if tieredBillingApplied {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
logParams := model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: summary.PromptTokens,
CompletionTokens: summary.CompletionTokens,
@@ -533,8 +548,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens))
})
}
if relayInfo.DurableAttemptID != 0 { recordDurableAttempt(ctx,relayInfo,billingUsage,logParams) } else { model.RecordConsumeLog(ctx,relayInfo.UserId,logParams) }
relayInfo.PerformanceOutputTokens = int64(summary.CompletionTokens)
}
+274
View File
@@ -1,8 +1,10 @@
package service
import (
"fmt"
"math"
"net/http/httptest"
"os"
"testing"
"time"
@@ -18,11 +20,283 @@ import (
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// The configured DSNs must point at isolated test databases. Each dialect runs
// the real reservation, settlement and log paths with the same billing cases.
func TestFixedPriceBillingDatabaseMatrix(t *testing.T) {
for _, dialect := range []struct {
name common.DatabaseType
env string
logEnv string
}{
{common.DatabaseTypeSQLite, "", ""},
{common.DatabaseTypeMySQL, "TEST_FIXED_MYSQL_DSN", "TEST_FIXED_MYSQL_LOG_DSN"},
{common.DatabaseTypePostgreSQL, "TEST_FIXED_POSTGRES_DSN", "TEST_FIXED_POSTGRES_LOG_DSN"},
} {
t.Run(string(dialect.name), func(t *testing.T) {
var driver gorm.Dialector = sqlite.Open(":memory:")
if dialect.env != "" {
dsn := os.Getenv(dialect.env)
if dsn == "" {
t.Skip(dialect.env + " is not configured")
}
if dialect.name == common.DatabaseTypeMySQL {
driver = mysql.Open(dsn)
} else {
driver = postgres.New(postgres.Config{DSN: dsn, PreferSimpleProtocol: true})
}
}
db, err := gorm.Open(driver, &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
logDB := db
if logDSN := os.Getenv(dialect.logEnv); logDSN != "" || dialect.name == common.DatabaseTypeSQLite {
var logDriver gorm.Dialector = sqlite.Open(":memory:")
if dialect.name == common.DatabaseTypeMySQL {
logDriver = mysql.Open(logDSN)
} else if dialect.name == common.DatabaseTypePostgreSQL {
logDriver = postgres.New(postgres.Config{DSN: logDSN, PreferSimpleProtocol: true})
}
logDB, err = gorm.Open(logDriver, &gorm.Config{})
require.NoError(t, err)
logSQL, err := logDB.DB()
require.NoError(t, err)
logSQL.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, logSQL.Close()) })
}
oldDB, oldLogDB := model.DB, model.LOG_DB
oldMainType, oldLogType := common.MainDatabaseType(), common.LogDatabaseType()
model.DB, model.LOG_DB = db, logDB
common.SetDatabaseTypes(dialect.name, dialect.name)
t.Cleanup(func() { model.DB, model.LOG_DB = oldDB, oldLogDB; common.SetDatabaseTypes(oldMainType, oldLogType) })
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Token{}, &model.Channel{}))
require.NoError(t, logDB.AutoMigrate(&model.Log{}))
versionQuery := "select version()"
if dialect.name == common.DatabaseTypeSQLite {
versionQuery = "select sqlite_version()"
}
var version string
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
t.Logf("database: %s", version)
runFixedPriceAccountingCases(t, db, logDB)
})
}
}
func runFixedPriceAccountingCases(t *testing.T, db, logDB *gorm.DB) {
t.Helper()
const mixed = `len <= 32000 ? tier("short", fixed(0.01)) : tier("long", p * 2)`
const flat = `tier("request", fixed(0.01))`
const startingQuota = 2_000_000
const imageExpression = `tier("standard", p * 5 + cr * 1.25 + img * 8 + img_cr * 2 + c * 30)`
imageUsage := &dto.Usage{PromptTokens: 1000, CompletionTokens: 100, TotalTokens: 1100,
PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 300, ImageTokens: 600, CachedTokensDetails: &dto.CachedTokenDetails{ImageTokens: common.GetPointer(200)}}}
operation_setting.SetToolPriceForTest("fixed_billing_tool", 4)
t.Cleanup(func() { operation_setting.DeleteToolPriceForTest("fixed_billing_tool") })
for index, tc := range []struct {
name, expression string
estimate int
usage *dto.Usage
audio, stream, refund, insufficient, tool bool
realtime, reserveInsufficient bool
wallet, outboundImages int
groupRatio float64
want int
unit billingexpr.BillingUnit
requestedImages, actualImages int
}{
{name: "missing usage charges once", expression: flat, want: 5000, unit: billingexpr.BillingUnitRequest},
{name: "zero usage charges once", expression: flat, usage: &dto.Usage{}, want: 5000, unit: billingexpr.BillingUnitRequest},
{name: "stream charges once", expression: flat, stream: true, usage: &dto.Usage{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120}, want: 5000, unit: billingexpr.BillingUnitRequest},
{name: "audio zero usage charges once", expression: flat, audio: true, usage: &dto.Usage{}, want: 5000, unit: billingexpr.BillingUnitRequest},
{name: "audio missing usage charges once", expression: flat, audio: true, want: 5000, unit: billingexpr.BillingUnitRequest},
{name: "token reservation refunds to fixed price", expression: mixed, estimate: 50000, usage: &dto.Usage{PromptTokens: 100, TotalTokens: 100}, want: 5000, unit: billingexpr.BillingUnitRequest},
{name: "fixed reservation settles token fallback", expression: mixed, estimate: 100, usage: &dto.Usage{PromptTokens: 50000, TotalTokens: 50000}, want: 50000, unit: billingexpr.BillingUnitToken},
{name: "missing usage uses estimated token fallback", expression: mixed, estimate: 50000, want: 50000, unit: billingexpr.BillingUnitToken},
{name: "evaluation error retains fixed reservation metadata", expression: `p == 50 ? tier("error", param("missing") * p + img_cr * 2) : tier("request", fixed(0.01))`, estimate: 100, usage: &dto.Usage{PromptTokens: 50, TotalTokens: 50}, want: 5000, unit: billingexpr.BillingUnitRequest},
{name: "explicit zero remains free", expression: `tier("free", fixed(0))`, usage: &dto.Usage{PromptTokens: 100, TotalTokens: 100}, unit: billingexpr.BillingUnitRequest},
{name: "multipliers and separate tool surcharge", expression: flat + ` * (param("fast") == true ? 2 : 1)`, groupRatio: 1.5, tool: true, want: 18000, unit: billingexpr.BillingUnitRequest},
{name: "failed request refunds exactly once", expression: flat, refund: true},
{name: "insufficient wallet never reserves tokens", expression: flat, insufficient: true},
{name: "image cache stream settles usage and refunds unused reservation", expression: imageExpression, estimate: 10000, usage: imageUsage, stream: true, want: 4113, unit: billingexpr.BillingUnitToken},
{name: "audio settlement records image cache billing inputs", expression: imageExpression, estimate: 10000, usage: imageUsage, audio: true, want: 4113, unit: billingexpr.BillingUnitToken},
{name: "realtime records actual expression inputs", expression: imageExpression, estimate: 10000, usage: imageUsage, realtime: true, want: 4000, unit: billingexpr.BillingUnitToken},
{name: "image cache insufficient wallet never reserves tokens", expression: imageExpression, estimate: 10000, insufficient: true},
{name: "image quantity refunds missing images", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 3, actualImages: 2, want: 40000, unit: billingexpr.BillingUnitRequest},
{name: "image quantity keeps request when actual missing", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 3, want: 60000, unit: billingexpr.BillingUnitRequest},
{name: "image quantity zero price stays free", expression: `tier("image", fixed(0)) * image_count`, requestedImages: 3, actualImages: 2, unit: billingexpr.BillingUnitRequest},
{name: "image quantity failure refunds reservation", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 3, refund: true},
{name: "image quantity exceeds one-image wallet before submission", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 4, wallet: 20000, insufficient: true},
{name: "image override reserves extra quantity", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 1, outboundImages: 4, want: 80000, unit: billingexpr.BillingUnitRequest},
{name: "image override cannot exceed remaining wallet", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 1, outboundImages: 4, wallet: 40000, reserveInsufficient: true, refund: true},
} {
t.Run(tc.name, func(t *testing.T) {
quota := startingQuota
if tc.insufficient {
quota = 1
}
if tc.wallet > 0 {
quota = tc.wallet
}
user := model.User{Username: fmt.Sprintf("fixed_billing_%d", index), Quota: quota, Status: common.UserStatusEnabled}
require.NoError(t, db.Create(&user).Error)
token := model.Token{UserId: user.Id, Key: fmt.Sprintf("fixed-billing-test-%d", index), Name: "fixed-billing", RemainQuota: startingQuota, Status: common.TokenStatusEnabled}
require.NoError(t, db.Create(&token).Error)
channel := model.Channel{Name: "fixed-billing", Key: "unused", Status: common.ChannelStatusEnabled}
require.NoError(t, db.Create(&channel).Error)
t.Cleanup(func() {
require.NoError(t, logDB.Where("user_id = ?", user.Id).Delete(&model.Log{}).Error)
require.NoError(t, db.Unscoped().Delete(&token).Error)
require.NoError(t, db.Unscoped().Delete(&user).Error)
require.NoError(t, db.Unscoped().Delete(&channel).Error)
})
group := tc.groupRatio
if group == 0 {
group = 1
}
request := &billingexpr.RequestInput{Body: []byte(`{"fast":true}`)}
if tc.requestedImages > 0 {
request.ImageCount = &tc.requestedImages
}
cost, trace, err := billingexpr.RunExprWithRequest(tc.expression, billingexpr.TokenParams{P: float64(tc.estimate), Len: float64(tc.estimate)}, *request)
require.NoError(t, err)
reservation, err := billingexpr.QuotaRoundStrict(cost / 1_000_000 * common.QuotaPerUnit * group)
require.NoError(t, err)
snapshot := &billingexpr.BillingSnapshot{BillingMode: "tiered_expr", ExprString: tc.expression, ExprHash: billingexpr.ExprHashString(tc.expression), QuotaPerUnit: common.QuotaPerUnit, GroupRatio: group, EstimatedTier: trace.MatchedTier, EstimatedBillingUnit: trace.BillingUnit, EstimatedFixedPrice: trace.FixedPrice, EstimatedQuotaAfterGroup: reservation}
snapshot.EstimatedImageCount = trace.ImageCount
info := &relaycommon.RelayInfo{UserId: user.Id, TokenId: token.Id, TokenKey: token.Key, ChannelMeta: &relaycommon.ChannelMeta{ChannelId: channel.Id}, OriginModelName: "fixed-test", UsingGroup: "default", UserGroup: "default", UserSetting: dto.UserSetting{BillingPreference: "wallet_only"}, ForcePreConsume: true, StartTime: time.Now(), IsStream: tc.stream, RelayFormat: types.RelayFormatOpenAI, PriceData: hosttypes.PriceData{GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: group}}, TieredBillingSnapshot: snapshot, BillingRequestInput: request}
info.SetEstimatePromptTokens(tc.estimate)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
if tc.expression == imageExpression {
ctx.Request.URL.Path = "/v1/images/generations"
info.RelayMode = relayconstant.RelayModeImagesGenerations
}
apiErr := PreConsumeBilling(ctx, reservation, info)
if tc.insufficient {
require.NotNil(t, apiErr)
assert.Equal(t, types.ErrorCodeInsufficientUserQuota, apiErr.GetErrorCode())
} else {
require.Nil(t, apiErr)
held, err := model.GetUserQuota(user.Id, true)
require.NoError(t, err)
assert.Equal(t, quota-reservation, held)
if tc.outboundImages > 0 {
reserveErr := PrepareImageBillingForRequest(ctx, info, tc.outboundImages, false)
if tc.reserveInsufficient {
require.NotNil(t, reserveErr)
assert.Equal(t, types.ErrorCodeInsufficientUserQuota, reserveErr.GetErrorCode())
assert.Equal(t, reservation, info.Billing.GetPreConsumedQuota())
} else {
require.Nil(t, reserveErr)
assert.Equal(t, tc.want, info.Billing.GetPreConsumedQuota())
}
}
if tc.refund {
refunded := make(chan struct{}, 1)
const callback = "fixed_billing_refund_observed"
require.NoError(t, db.Callback().Update().After("gorm:commit_or_rollback_transaction").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == "tokens" && tx.Error == nil {
select {
case refunded <- struct{}{}:
default:
}
}
}))
t.Cleanup(func() { require.NoError(t, db.Callback().Update().Remove(callback)) })
info.Billing.Refund(ctx)
info.Billing.Refund(ctx)
select {
case <-refunded:
case <-time.After(5 * time.Second):
t.Fatal("refund did not finish")
}
} else {
info.UpdateImageCount(int64(tc.actualImages))
if tc.tool {
info.ResponsesUsageInfo = &relaycommon.ResponsesUsageInfo{BuiltInTools: map[string]*relaycommon.BuildInToolInfo{"fixed_billing_tool": {CallCount: 1}}}
}
if tc.realtime {
PostWssConsumeQuota(ctx, info, info.OriginModelName, &dto.RealtimeUsage{
InputTokens: tc.usage.PromptTokens, OutputTokens: tc.usage.CompletionTokens, TotalTokens: tc.usage.TotalTokens,
}, "")
} else if tc.audio {
PostAudioConsumeQuota(ctx, info, tc.usage, "")
} else {
PostTextConsumeQuota(ctx, info, tc.usage, nil)
}
require.NoError(t, info.Billing.Settle(tc.want), "a repeated settlement must not charge again")
var log model.Log
require.NoError(t, logDB.Where("user_id = ?", user.Id).Take(&log).Error)
assert.Equal(t, tc.want, log.Quota)
assert.Equal(t, tc.stream, log.IsStream)
assert.NotContains(t, log.Content, "无法扣费")
var other map[string]any
require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
assert.Equal(t, string(tc.unit), other["billing_unit"])
if tc.requestedImages > 0 {
count := tc.actualImages
if count == 0 {
count = tc.requestedImages
if tc.outboundImages > 0 {
count = tc.outboundImages
}
}
assert.Equal(t, float64(count), other["image_count"])
assert.Equal(t, tc.requestedImages, *request.ImageCount, "actual count must not mutate the frozen request")
}
if tc.expression == imageExpression {
billable, ok := other["billing_tokens"].(map[string]any)
require.True(t, ok)
if tc.realtime {
assert.Equal(t, float64(0), other["image_cache_tokens"])
assert.Equal(t, float64(1000), billable["p"])
assert.Equal(t, float64(0), billable["cr"])
assert.Equal(t, float64(0), billable["img"])
} else {
if !tc.audio {
assert.Equal(t, float64(300), other["cache_tokens"])
}
assert.Equal(t, float64(200), other["image_cache_tokens"])
assert.Equal(t, float64(300), billable["p"])
assert.Equal(t, float64(100), billable["cr"])
assert.Equal(t, float64(400), billable["img"])
}
} else {
assert.NotContains(t, other, "billing_tokens")
assert.NotContains(t, other, "image_cache_tokens")
}
if tc.unit == billingexpr.BillingUnitRequest {
assert.Contains(t, other, "fixed_price")
} else {
assert.NotContains(t, other, "fixed_price")
}
}
}
require.NoError(t, db.First(&user, user.Id).Error)
require.NoError(t, db.First(&token, token.Id).Error)
assert.Equal(t, quota-tc.want, user.Quota)
assert.Equal(t, startingQuota-tc.want, token.RemainQuota)
assert.Equal(t, tc.want, user.UsedQuota)
assert.Equal(t, tc.want, token.UsedQuota)
if !tc.refund && !tc.insufficient {
assert.Equal(t, 1, user.RequestCount)
}
})
}
}
func TestCalculateTextQuotaSummaryUnifiedForClaudeSemantic(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
+69 -11
View File
@@ -3,6 +3,7 @@ package service
import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -35,6 +36,37 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
}
img := float64(usage.PromptTokensDetails.ImageTokens)
imgCR := float64(0)
if usedVars["img_cr"] && !isClaudeUsageSemantic {
details := usage.PromptTokensDetails.CachedTokensDetails
if details != nil && details.ImageTokens != nil {
cachedImage := *details.ImageTokens
cached := usage.PromptTokensDetails.CachedTokens
image := usage.PromptTokensDetails.ImageTokens
valid := cachedImage >= 0 && cached >= cachedImage && image >= cachedImage &&
cached <= usage.PromptTokens && image <= usage.PromptTokens-(cached-cachedImage)
if valid {
remaining := cached - cachedImage
for _, count := range []*int{details.TextTokens, details.AudioTokens} {
if count == nil {
continue
}
if *count < 0 || *count > remaining {
valid = false
break
}
remaining -= *count
}
}
if valid {
imgCR = float64(cachedImage)
cr -= imgCR
img -= imgCR
} else {
common.SysError("invalid image cache token breakdown; using aggregate cache billing")
}
}
}
ai := float64(usage.PromptTokensDetails.AudioTokens)
imgO := float64(usage.CompletionTokenDetails.ImageTokens)
ao := float64(usage.CompletionTokenDetails.AudioTokens)
@@ -47,7 +79,13 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
inputLen = p + cr + cc5m + cc1h
}
if !isClaudeUsageSemantic {
if isClaudeUsageSemantic {
// Anthropic input excludes cache reads. When cr has no separate
// price, merge those tokens into the input category instead.
if !usedVars["cr"] {
p += cr
}
} else {
if usedVars["cr"] {
p -= cr
}
@@ -60,6 +98,9 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
if usedVars["img"] {
p -= img
}
if usedVars["img_cr"] {
p -= imgCR
}
if usedVars["ai"] {
p -= ai
}
@@ -81,16 +122,17 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
}
return billingexpr.TokenParams{
P: p,
C: c,
Len: inputLen,
CR: cr,
CC: cc5m,
CC1h: cc1h,
Img: img,
ImgO: imgO,
AI: ai,
AO: ao,
P: p,
C: c,
Len: inputLen,
CR: cr,
CC: cc5m,
CC1h: cc1h,
Img: img,
ImgCR: imgCR,
ImgO: imgO,
AI: ai,
AO: ao,
}
}
@@ -135,6 +177,7 @@ func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon
if snap == nil {
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
@@ -170,6 +213,11 @@ func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenP
if relayInfo.BillingRequestInput != nil {
requestInput = *relayInfo.BillingRequestInput
}
if relayInfo.BillingImageCount != nil {
requestInput.ImageCount = relayInfo.BillingImageCount
} else if snap.EstimatedImageCount != nil {
requestInput.ImageCount = snap.EstimatedImageCount
}
tr, err := billingexpr.ComputeTieredQuotaWithRequest(snap, params, requestInput)
if err != nil {
@@ -187,3 +235,13 @@ func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenP
return true, tr.ActualQuotaAfterGroup, &tr
}
// A failed evaluation retains the reservation and its estimated billing unit.
// Successful evaluations always use the actual branch, including zero prices.
func isFixedPriceSettlement(info *relaycommon.RelayInfo, result *billingexpr.TieredResult) bool {
if result != nil {
return result.BillingUnit == billingexpr.BillingUnitRequest
}
snap := info.TieredBillingSnapshot
return snap != nil && snap.BillingMode == "tiered_expr" && snap.EstimatedBillingUnit == billingexpr.BillingUnitRequest
}
+65
View File
@@ -5,6 +5,7 @@ import (
"math/rand"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -752,6 +753,42 @@ func TestBuildTieredTokenParams_GPT_WithImage(t *testing.T) {
}
}
func TestImageCacheBilling(t *testing.T) {
const expression = `tier("standard", p * 5 + cr * 1.25 + img * 8 + img_cr * 2 + c * 30)`
for _, tc := range []struct {
name string
details *dto.CachedTokenDetails
expression string
p, cr, img, imgCR float64
quota int
}{
{"mixed cache", &dto.CachedTokenDetails{ImageTokens: common.GetPointer(200), TextTokens: common.GetPointer(100)}, expression, 300, 100, 400, 200, 4113},
{"explicit free image cache price", &dto.CachedTokenDetails{ImageTokens: common.GetPointer(200)}, `tier("standard", p * 5 + cr * 1.25 + img * 8 + img_cr * 0 + c * 30)`, 300, 100, 400, 200, 3913},
{"explicit zero", &dto.CachedTokenDetails{ImageTokens: common.GetPointer(0)}, expression, 100, 300, 600, 0, 4338},
{"missing breakdown", nil, expression, 100, 300, 600, 0, 4338},
{"missing image modality", &dto.CachedTokenDetails{TextTokens: common.GetPointer(100)}, expression, 100, 300, 600, 0, 4338},
{"negative image count", &dto.CachedTokenDetails{ImageTokens: common.GetPointer(-1)}, expression, 100, 300, 600, 0, 4338},
{"image count exceeds cache", &dto.CachedTokenDetails{ImageTokens: common.GetPointer(301)}, expression, 100, 300, 600, 0, 4338},
{"modality sum exceeds cache", &dto.CachedTokenDetails{ImageTokens: common.GetPointer(200), TextTokens: common.GetPointer(101)}, expression, 100, 300, 600, 0, 4338},
{"old expression unchanged", &dto.CachedTokenDetails{ImageTokens: common.GetPointer(200)}, `p * 5 + cr * 1.25 + img * 8 + c * 30`, 100, 300, 600, 0, 4338},
{"only image cache separately priced", &dto.CachedTokenDetails{ImageTokens: common.GetPointer(200)}, `p * 5 + img_cr * 2 + c * 30`, 800, 100, 400, 200, 3700},
} {
t.Run(tc.name, func(t *testing.T) {
usage := &dto.Usage{PromptTokens: 1000, CompletionTokens: 100,
PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 300, ImageTokens: 600, CachedTokensDetails: tc.details}}
params := BuildTieredTokenParams(usage, false, billingexpr.UsedVars(tc.expression))
assert.Equal(t, tc.p, params.P)
assert.Equal(t, tc.cr, params.CR)
assert.Equal(t, tc.img, params.Img)
assert.Equal(t, tc.imgCR, params.ImgCR)
assert.Equal(t, float64(1000), params.Len)
result, err := billingexpr.ComputeTieredQuota(makeSnapshot(tc.expression, 1, 1000, 100), params)
require.NoError(t, err)
assert.Equal(t, tc.quota, result.ActualQuotaAfterGroup)
})
}
}
func TestBuildTieredTokenParams_Claude_WithCache(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 800,
@@ -1024,3 +1061,31 @@ func BenchmarkRatioBilling_Parallel(b *testing.B) {
}
})
}
func TestSamePriceCacheReadsRemainInInput(t *testing.T) {
for _, tc := range []struct {
name string
usage dto.Usage
extra string
input, cost float64
}{
{"openai", dto.Usage{PromptTokens: 1000, CompletionTokens: 100, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 200}}, "", 1000, 72000},
{"anthropic", dto.Usage{PromptTokens: 800, CompletionTokens: 100, UsageSemantic: "anthropic", PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 200}}, "", 1000, 72000},
{"anthropic mixed cache TTLs", dto.Usage{PromptTokens: 750, CompletionTokens: 100, UsageSemantic: "anthropic", ClaudeCacheCreation5mTokens: 30, ClaudeCacheCreation1hTokens: 20, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 200, CachedCreationTokens: 50}}, " + cc * 75 + cc1h * 120", 950, 73650},
} {
t.Run(tc.name, func(t *testing.T) {
merged := `tier("base", p * 60 + c * 120` + tc.extra + `)`
separate := `tier("base", p * 60 + c * 120 + cr * 60` + tc.extra + `)`
params := BuildTieredTokenParams(&tc.usage, tc.usage.UsageSemantic == "anthropic", billingexpr.UsedVars(merged))
assert.Equal(t, tc.input, params.P)
cost, _, err := billingexpr.RunExpr(merged, params)
require.NoError(t, err)
assert.Equal(t, tc.cost, cost)
separateParams := BuildTieredTokenParams(&tc.usage, tc.usage.UsageSemantic == "anthropic", billingexpr.UsedVars(separate))
previousCost, _, err := billingexpr.RunExpr(separate, separateParams)
require.NoError(t, err)
assert.Equal(t, previousCost, cost, "folding cache reads into input must preserve the charge")
assert.Equal(t, separateParams.Len, params.Len, "tier conditions keep the full context length")
})
}
}
+185
View File
@@ -0,0 +1,185 @@
package service
import (
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/cachex"
"github.com/gin-gonic/gin"
"github.com/samber/hot"
)
type poolBinding struct {
Channel int `json:"channel"`
Group string `json:"group"`
}
var poolBindingOnce sync.Once
var poolBindingCache *cachex.HybridCache[poolBinding]
func poolBindings() *cachex.HybridCache[poolBinding] {
poolBindingOnce.Do(func() {
poolBindingCache = cachex.NewHybridCache[poolBinding](cachex.HybridCacheConfig[poolBinding]{Namespace: cachex.Namespace("new-api:pool-affinity:v1"), Redis: common.RDB, RedisEnabled: func() bool { return common.RedisEnabled && common.RDB != nil }, RedisCodec: cachex.JSONCodec[poolBinding]{}, Memory: func() *hot.HotCache[string, poolBinding] {
return hot.NewHotCache[string, poolBinding](hot.LRU, 100000).WithTTL(time.Hour).Build()
}})
})
return poolBindingCache
}
func PreferredPoolChannel(c *gin.Context, modelName string) (*model.Channel, string) {
// Resolve the existing session-key rules, but isolate the binding by owner,
// token and original model rather than the currently selected group.
GetPreferredChannelByAffinity(c, modelName, "pool")
meta, ok := getChannelAffinityMeta(c)
if !ok || RequestPolicy(c).SessionMode == "off" {
return nil, ""
}
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)
binding, found, err := poolBindings().Get(key)
if err != nil {
return nil, ""
}
if !found {
// Adopt only a successful old binding attributable to this exact owner,
// token, model and session. Never trust a legacy unscoped cache alone.
appendRequestDetails(c, nil, model.NewLogOther())
correlation := c.GetString("log_correlation_key")
if correlation == "" || model.LOG_DB == nil {
return nil, ""
}
var previous model.Log
if model.LOG_DB.Where("user_id = ? AND token_id = ? AND model_name = ? AND correlation_key = ? AND type = ? AND created_at > ?", c.GetInt("id"), c.GetInt("token_id"), modelName, correlation, model.LogTypeConsume, time.Now().Unix()-int64(meta.TTLSeconds)).Order("id desc").First(&previous).Error != nil {
return nil, ""
}
var other struct {
Result string `json:"model_guard_result"`
Stream struct {
Status string `json:"status"`
EndReason string `json:"end_reason"`
Response string `json:"response_status"`
} `json:"stream_status"`
}
_ = common.UnmarshalJsonStr(previous.Other, &other)
if other.Result != "" {
return nil, ""
}
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}
}
for _, entry := range ResolveRequestPool(c, modelName) {
if entry.Group != binding.Group {
continue
}
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 {
common.SetContextKey(c, constant.ContextKeyAutoGroup, entry.Group)
MarkChannelAffinityUsed(c, entry.Group, channel.Id)
recordDirectSelection(c, channel, channel.Id, entry.Group, modelName, "pool_affinity_hit")
return channel, entry.Group
}
}
}
ClearPoolAffinity(c)
return nil, ""
}
func ClearPoolAffinity(c *gin.Context) {
if key := c.GetString("pool_affinity_key"); key != "" {
ttl := c.GetInt("pool_affinity_ttl")
if ttl <= 0 {
ttl = 3600
}
// A tombstone prevents re-adopting the same historical binding after a
// rejection. A later successful generation replaces it normally.
_ = poolBindings().SetWithTTL(key, poolBinding{}, time.Duration(ttl)*time.Second)
}
ClearCurrentChannelAffinityCache(c)
}
func RecordPoolAffinity(c *gin.Context) {
if !RequestPolicy(c).Successful {
return
}
key := c.GetString("pool_affinity_key")
if key == "" {
return
}
ttl := c.GetInt("pool_affinity_ttl")
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)
}
func RequestRoutingPolicy(c *gin.Context) *model.TokenRoutingPolicy {
value, _ := c.Get(model.TokenRoutingContextKey)
policy, _ := value.(*model.TokenRoutingPolicy)
return policy
}
func ResolveRequestPool(c *gin.Context, modelName string) []model.TokenGroupEntry {
policy := RequestRoutingPolicy(c)
if policy == nil {
return nil
}
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
inherited := GetUserAutoGroup(userGroup)
if policy.Mode == "user" {
inherited = []string{userGroup}
}
return policy.Resolve(modelName, inherited, func(group string) bool { return IsUserSelectableGroup(userGroup, group) })
}
func selectTokenRoutingPool(param *RetryParam) (*model.Channel, string, error) {
entries := ResolveRequestPool(param.Ctx, param.ModelName)
constraints := GetChannelConstraints(param.Ctx)
filters := append([]dto.ChannelFilter{}, constraints.Filters...)
var excluded []int
for _, value := range param.Ctx.GetStringSlice("use_channel") {
id, _ := strconv.Atoi(value)
excluded = append(excluded, id)
}
filters = append(filters, dto.ChannelFilter{Kind: dto.FilterAttemptedChannel, ExcludedChannelIDs: excluded})
for start := 0; start < len(entries); {
end := start + 1
for end < len(entries) && entries[end].Priority == entries[start].Priority {
end++
}
groups := entries[start:end]
var names []string
for _, entry := range groups {
names = append(names, entry.Group)
}
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) {
continue
}
trace := &model.SelectionTrace{}
channel, err := model.GetRandomSatisfiedChannel(group, param.ModelName, 0, filters, trace)
if err != nil {
return nil, group, err
}
if channel != nil {
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, group)
recordDirectSelection(param.Ctx, channel, channel.Id, group, param.ModelName, "model_pool_priority")
return channel, group, nil
}
}
start = end
}
return nil, param.TokenGroup, nil
}
+69
View File
@@ -0,0 +1,69 @@
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/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"net/http/httptest"
"testing"
)
func TestTokenRoutingPoolPreferenceExclusionAndAffinityIsolation(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "pool-model"
createChannelSelectAutoGroupsChannel(t, db, 6101, "default", modelName)
createChannelSelectAutoGroupsChannel(t, db, 6102, "vip", modelName)
model.InitChannelCache()
ResetGroupAvailability()
t.Cleanup(ResetGroupAvailability)
config := operation_setting.GetChannelAffinitySetting()
previous := *config
*config = operation_setting.ChannelAffinitySetting{Enabled: true, DefaultTTLSeconds: 3600, SessionMode: "prefer", Rules: []operation_setting.ChannelAffinityRule{{Name: "pool-test", ModelRegex: []string{".*"}, KeySources: []operation_setting.ChannelAffinityKeySource{{Type: "request_header", Key: "Session-Id"}}, SessionMode: "prefer"}}}
t.Cleanup(func() { *config = previous; _ = poolBindings().Purge() })
ctx := func(token int, session string) *gin.Context {
c := setupGroupPoolContext(t)
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
c.Request.Header.Set("Session-Id", session)
c.Set("id", 100)
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}}, ModelRules: []model.ModelRoutingRule{{Model: modelName, Groups: []model.TokenGroupEntry{{Group: "vip", Priority: 0}}}}})
return c
}
c := ctx(100, "session-a")
param := &RetryParam{Ctx: c, TokenGroup: "default", ModelName: modelName, Retry: common.GetPointer(0)}
first, group, err := SelectChannelForRequest(c, modelName, param)
require.Nil(t, err)
require.NotNil(t, first)
require.Equal(t, 6102, first.Id)
require.Equal(t, "vip", group)
AppendUsedChannel(c, first.Id)
second, group, err2 := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err2)
require.NotNil(t, second)
require.Equal(t, 6101, second.Id)
c.Set("channel_id", second.Id)
common.SetContextKey(c, constant.ContextKeyAutoGroup, group)
RequestPolicy(c).Successful = true
RecordPoolAffinity(c)
next := ctx(100, "session-a")
bound, boundGroup := PreferredPoolChannel(next, modelName)
require.NotNil(t, bound)
require.Equal(t, 6101, bound.Id)
require.Equal(t, "default", boundGroup)
isolated, _ := PreferredPoolChannel(ctx(101, "session-a"), modelName)
require.Nil(t, isolated)
isolated, _ = PreferredPoolChannel(ctx(100, "session-b"), modelName)
require.Nil(t, isolated)
isolated, _ = PreferredPoolChannel(ctx(100, "session-a"), "another-model")
require.Nil(t, isolated)
ClearPoolAffinity(next)
bound, _ = PreferredPoolChannel(ctx(100, "session-a"), modelName)
require.Nil(t, bound)
AppendUsedChannel(c, second.Id)
last, _, err2 := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err2)
require.Nil(t, last)
}
+13
View File
@@ -0,0 +1,13 @@
package service
import "github.com/QuantumNous/new-api/pkg/wsmanager"
const ChannelDisabledCloseReason = "channel disabled or deleted"
func CloseActiveWebSocketsForChannel(channelID int, reason string) int {
return wsmanager.CloseChannelsAndBroadcast([]int{channelID}, reason)
}
func CloseActiveWebSocketsForChannels(channelIDs []int, reason string) int {
return wsmanager.CloseChannelsAndBroadcast(channelIDs, reason)
}