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:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ", ")
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)语义一致:
|
||||
// 全额无条件扣减,余额不足的部分记为欠费(余额可为负),不中断请求,
|
||||
// 保证日志记录的预扣额度与用户余额的实际变动始终对账一致。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬ification))
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user