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
@@ -0,0 +1,63 @@
|
||||
package perfmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var liveSuccess sync.Map
|
||||
|
||||
type successTime struct {
|
||||
sync.Mutex
|
||||
at int64
|
||||
}
|
||||
|
||||
func recordUserSuccess(group string, at int64) {
|
||||
if group == "" {
|
||||
return
|
||||
}
|
||||
value, _ := liveSuccess.LoadOrStore(group, &successTime{})
|
||||
v := value.(*successTime)
|
||||
v.Lock()
|
||||
v.at = max(v.at, at)
|
||||
v.Unlock()
|
||||
if common.RedisEnabled && common.RDB != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
// A delayed writer must not replace a newer successful request.
|
||||
_ = common.RDB.Eval(ctx, `local old=tonumber(redis.call('GET',KEYS[1]) or '0'); if tonumber(ARGV[1])>old then redis.call('SET',KEYS[1],ARGV[1],'EX',900) end; return 1`, []string{"status:user-success:" + group}, at).Err()
|
||||
}
|
||||
}
|
||||
|
||||
func LatestUserSuccess(ctx context.Context, groups []string) map[string]int64 {
|
||||
result := map[string]int64{}
|
||||
for _, group := range groups {
|
||||
if value, ok := liveSuccess.Load(group); ok {
|
||||
v := value.(*successTime)
|
||||
v.Lock()
|
||||
result[group] = v.at
|
||||
v.Unlock()
|
||||
}
|
||||
}
|
||||
if common.RedisEnabled && common.RDB != nil && len(groups) > 0 {
|
||||
keys := make([]string, len(groups))
|
||||
for i, g := range groups {
|
||||
keys[i] = "status:user-success:" + g
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
values, err := common.RDB.MGet(ctx, keys...).Result()
|
||||
if err == nil {
|
||||
for i, value := range values {
|
||||
if s, ok := value.(string); ok {
|
||||
at, _ := strconv.ParseInt(s, 10, 64)
|
||||
result[groups[i]] = max(result[groups[i]], at)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package perfmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLiveUserSuccess(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
for _, name := range []string{"user", "probe", "internal", "failed"} {
|
||||
liveSuccess.Delete(name)
|
||||
}
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
test bool
|
||||
token int
|
||||
fail bool
|
||||
want bool
|
||||
}{
|
||||
{"user", false, 1, false, true}, {"probe", true, 1, false, false}, {"internal", false, 0, false, false}, {"failed", false, 1, true, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var apiErr *types.NewAPIError
|
||||
if tc.fail {
|
||||
apiErr = types.InitOpenAIError("server_error", 500)
|
||||
}
|
||||
RecordRelayResult(context.Background(), &relaycommon.RelayInfo{UsingGroup: tc.name, TokenId: tc.token, IsChannelTest: tc.test, StartTime: time.Now()}, apiErr)
|
||||
assert.Equal(t, tc.want, LatestUserSuccess(context.Background(), []string{tc.name})[tc.name] > 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
+83
-22
@@ -11,7 +11,10 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"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/perf_metrics_setting"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
)
|
||||
|
||||
var hotBuckets sync.Map
|
||||
@@ -24,11 +27,20 @@ func Init() {
|
||||
go flushLoop()
|
||||
}
|
||||
|
||||
func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens int64) {
|
||||
// RecordRelayResult samples one finished relay exactly once, at the request
|
||||
// boundary, regardless of how many channel attempts it took.
|
||||
func RecordRelayResult(ctx context.Context, info *relaycommon.RelayInfo, apiErr *types.NewAPIError) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
outcome := ClassifyRelayOutcome(ctx, info, apiErr)
|
||||
if outcome == OutcomeIgnored {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if outcome == OutcomeSuccess && !info.IsChannelTest && info.TokenId > 0 {
|
||||
recordUserSuccess(info.UsingGroup, now.Unix())
|
||||
}
|
||||
hasTtft := info.IsStream && info.HasSendResponse()
|
||||
ttftMs := int64(0)
|
||||
if hasTtft {
|
||||
@@ -48,8 +60,8 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens i
|
||||
LatencyMs: latencyMs,
|
||||
TtftMs: ttftMs,
|
||||
HasTtft: hasTtft,
|
||||
Success: success,
|
||||
OutputTokens: outputTokens,
|
||||
Success: outcome == OutcomeSuccess,
|
||||
OutputTokens: info.PerformanceOutputTokens,
|
||||
GenerationMs: generationMs,
|
||||
})
|
||||
}
|
||||
@@ -73,18 +85,25 @@ func Record(sample Sample) {
|
||||
}
|
||||
actual, _ := hotBuckets.LoadOrStore(key, &atomicBucket{})
|
||||
actual.(*atomicBucket).add(sample)
|
||||
recordRedis(key, sample)
|
||||
gopool.Go(func() {
|
||||
recordRedis(key, sample)
|
||||
})
|
||||
}
|
||||
|
||||
// queryWindow covers the current partial hour plus the preceding complete
|
||||
// hours, so every reader shares the same hourly buckets.
|
||||
func queryWindow(now time.Time, hours int) (int64, int64) {
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
hours = min(hours, 24*30)
|
||||
endTs := now.Unix()
|
||||
return endTs - endTs%3600 - int64(hours-1)*3600, endTs
|
||||
}
|
||||
|
||||
func Query(params QueryParams) (QueryResult, error) {
|
||||
if params.Hours <= 0 {
|
||||
params.Hours = 24
|
||||
}
|
||||
if params.Hours > 24*30 {
|
||||
params.Hours = 24 * 30
|
||||
}
|
||||
endTs := time.Now().Unix()
|
||||
startTs := endTs - int64(params.Hours)*3600
|
||||
startTs, endTs := queryWindow(time.Now(), params.Hours)
|
||||
allowedGroups := allowedGroupSet(params.AllowedGroups)
|
||||
|
||||
merged := map[bucketKey]counters{}
|
||||
rows, err := model.GetPerfMetrics(params.Model, params.Group, startTs, endTs)
|
||||
@@ -92,6 +111,11 @@ func Query(params QueryParams) (QueryResult, error) {
|
||||
return QueryResult{}, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if allowedGroups != nil {
|
||||
if _, ok := allowedGroups[row.Group]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
mergeCounters(merged, bucketKey{
|
||||
model: row.ModelName,
|
||||
group: row.Group,
|
||||
@@ -115,22 +139,22 @@ func Query(params QueryParams) (QueryResult, error) {
|
||||
if params.Group != "" && k.group != params.Group {
|
||||
return true
|
||||
}
|
||||
if allowedGroups != nil {
|
||||
if _, ok := allowedGroups[k.group]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
mergeCounters(merged, k, value.(*atomicBucket).snapshot())
|
||||
return true
|
||||
})
|
||||
|
||||
return buildQueryResult(params.Model, merged), nil
|
||||
result := buildQueryResult(params.Model, merged)
|
||||
result.WindowStart, result.WindowEnd = startTs, endTs
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
if hours > 24*30 {
|
||||
hours = 24 * 30
|
||||
}
|
||||
endTs := time.Now().Unix()
|
||||
startTs := endTs - int64(hours)*3600
|
||||
startTs, endTs := queryWindow(time.Now(), hours)
|
||||
allowedGroups := allowedGroupSet(groups)
|
||||
|
||||
rows, err := model.GetPerfMetricsSummaryBucketsAll(startTs, endTs, groups)
|
||||
@@ -171,11 +195,17 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
return true
|
||||
})
|
||||
|
||||
all := counters{}
|
||||
models := make([]ModelSummary, 0, len(totals))
|
||||
for name, total := range totals {
|
||||
if total.requestCount == 0 {
|
||||
continue
|
||||
}
|
||||
all.requestCount += total.requestCount
|
||||
all.successCount += total.successCount
|
||||
all.totalLatencyMs += total.totalLatencyMs
|
||||
all.outputTokens += total.outputTokens
|
||||
all.generationMs += total.generationMs
|
||||
avgLatency := total.totalLatencyMs / total.requestCount
|
||||
successRate := float64(total.successCount) / float64(total.requestCount) * 100
|
||||
avgTps := 0.0
|
||||
@@ -195,7 +225,7 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
return models[i].RequestCount > models[j].RequestCount
|
||||
})
|
||||
|
||||
return SummaryAllResult{Models: models}, nil
|
||||
return SummaryAllResult{Summary: summarize(all), WindowStart: startTs, WindowEnd: endTs, Models: models}, nil
|
||||
}
|
||||
|
||||
// QueryGroups combines raw bucket counters so busy models carry their actual weight.
|
||||
@@ -324,6 +354,7 @@ func mergeCounters(merged map[bucketKey]counters, key bucketKey, value counters)
|
||||
|
||||
func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResult {
|
||||
groupBuckets := map[string]map[int64]counters{}
|
||||
modelBuckets := map[string]map[int64]counters{}
|
||||
for key, value := range merged {
|
||||
if value.requestCount == 0 {
|
||||
continue
|
||||
@@ -332,6 +363,7 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
|
||||
groupBuckets[key.group] = map[int64]counters{}
|
||||
}
|
||||
groupBuckets[key.group][key.bucketTs] = value
|
||||
mergeModelBucket(modelBuckets, modelName, key.bucketTs, value)
|
||||
}
|
||||
|
||||
groups := make([]string, 0, len(groupBuckets))
|
||||
@@ -340,6 +372,7 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
|
||||
}
|
||||
sort.Strings(groups)
|
||||
|
||||
all := counters{}
|
||||
results := make([]GroupResult, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
buckets := groupBuckets[group]
|
||||
@@ -364,6 +397,11 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
|
||||
total.generationMs += value.generationMs
|
||||
series = append(series, bucketPoint(ts, value))
|
||||
}
|
||||
all.requestCount += total.requestCount
|
||||
all.successCount += total.successCount
|
||||
all.totalLatencyMs += total.totalLatencyMs
|
||||
all.outputTokens += total.outputTokens
|
||||
all.generationMs += total.generationMs
|
||||
|
||||
results = append(results, GroupResult{
|
||||
Group: group,
|
||||
@@ -375,13 +413,36 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
|
||||
})
|
||||
}
|
||||
|
||||
modelSeries := make([]BucketPoint, 0, len(modelBuckets[modelName]))
|
||||
for ts, value := range modelBuckets[modelName] {
|
||||
modelSeries = append(modelSeries, bucketPoint(ts, value))
|
||||
}
|
||||
sort.Slice(modelSeries, func(i, j int) bool {
|
||||
return modelSeries[i].Ts < modelSeries[j].Ts
|
||||
})
|
||||
|
||||
return QueryResult{
|
||||
ModelName: modelName,
|
||||
SeriesSchema: seriesSchema,
|
||||
Summary: summarize(all),
|
||||
Series: modelSeries,
|
||||
Groups: results,
|
||||
}
|
||||
}
|
||||
|
||||
// summarize returns nil when nothing was sampled, so clients can show
|
||||
// "no data" instead of a fabricated 0% success rate.
|
||||
func summarize(total counters) *Summary {
|
||||
if total.requestCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &Summary{
|
||||
AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount),
|
||||
SuccessRate: math.Round(successRate(total)*100) / 100,
|
||||
AvgTps: math.Round(avgTps(total)*100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
func bucketPoint(ts int64, value counters) BucketPoint {
|
||||
return BucketPoint{
|
||||
totals: value,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package perfmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClassifyRelayOutcome(t *testing.T) {
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
err *types.NewAPIError
|
||||
want Outcome
|
||||
}{
|
||||
{"success", context.Background(), nil, OutcomeSuccess},
|
||||
{"upstream bad request", context.Background(), types.InitOpenAIError("unknown", 400), OutcomeIgnored},
|
||||
{"upstream credentials returned as 400", context.Background(), types.InitOpenAIError("invalid_api_key", 400), OutcomeFailure},
|
||||
{"wrapped credentials keep their cause", context.Background(), types.NewErrorWithStatusCode(types.InitOpenAIError("invalid_api_key", 400), types.ErrorCodeInvalidRequest, 400), OutcomeFailure},
|
||||
{"upstream context limit returned as 500", context.Background(), types.InitOpenAIError("context_length_exceeded", 500), OutcomeIgnored},
|
||||
{"local rate limit", context.Background(), types.NewErrorWithStatusCode(errors.New("limited"), types.ErrorCodeInvalidRequest, 429), OutcomeIgnored},
|
||||
{"upstream rate limit", context.Background(), types.InitOpenAIError("rate_limit_exceeded", 429), OutcomeFailure},
|
||||
{"local quota", context.Background(), types.NewError(errors.New("quota"), types.ErrorCodeInsufficientUserQuota), OutcomeIgnored},
|
||||
{"local violation fee", context.Background(), types.NewError(errors.New("csam"), types.ErrorCodeViolationFeeGrokCSAM), OutcomeIgnored},
|
||||
{"upstream quota", context.Background(), types.InitOpenAIError("insufficient_quota", 429), OutcomeFailure},
|
||||
{"upstream gateway quota", context.Background(), types.InitOpenAIError(types.ErrorCodeInsufficientUserQuota, 403), OutcomeFailure},
|
||||
{"unavailable channel", context.Background(), types.NewErrorWithStatusCode(errors.New("disabled"), types.ErrorCodeGetChannelFailed, 403), OutcomeFailure},
|
||||
{"empty upstream response", context.Background(), types.NewError(errors.New("empty"), types.ErrorCodeEmptyResponse), OutcomeFailure},
|
||||
{"network failure", context.Background(), types.NewOpenAIError(errors.New("connection refused"), types.ErrorCodeDoRequestFailed, 500), OutcomeFailure},
|
||||
{"client cancellation", canceled, types.NewOpenAIError(errors.New("context canceled"), types.ErrorCodeDoRequestFailed, 500), OutcomeIgnored},
|
||||
{"upstream deadline", context.Background(), types.NewOpenAIError(context.DeadlineExceeded, types.ErrorCodeDoRequestFailed, 504), OutcomeFailure},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, ClassifyRelayOutcome(tc.ctx, &relaycommon.RelayInfo{}, tc.err))
|
||||
})
|
||||
}
|
||||
assert.Equal(t, OutcomeIgnored, ClassifyRelayOutcome(context.Background(), &relaycommon.RelayInfo{PerformanceBusinessRejection: true}, nil))
|
||||
}
|
||||
|
||||
func TestStreamOutcomeClassification(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mark func(*relaycommon.StreamStatus)
|
||||
end relaycommon.StreamEndReason
|
||||
want Outcome
|
||||
}{
|
||||
{"completed", (*relaycommon.StreamStatus).MarkCompleted, relaycommon.StreamEndReasonEOF, OutcomeSuccess},
|
||||
{"business rejection", func(s *relaycommon.StreamStatus) { s.MarkFailed("context_length_exceeded", "", 0) }, relaycommon.StreamEndReasonEOF, OutcomeIgnored},
|
||||
{"service error", func(s *relaycommon.StreamStatus) { s.MarkFailed("server_error", "", 0) }, relaycommon.StreamEndReasonEOF, OutcomeFailure},
|
||||
{"error after completion", func(s *relaycommon.StreamStatus) { s.MarkCompleted(); s.MarkFailed("", "server_error", 0) }, relaycommon.StreamEndReasonEOF, OutcomeFailure},
|
||||
{"output limit", func(s *relaycommon.StreamStatus) { s.MarkIncomplete("max_output_tokens") }, relaycommon.StreamEndReasonEOF, OutcomeSuccess},
|
||||
{"content filter", func(s *relaycommon.StreamStatus) { s.MarkIncomplete("content_filter") }, relaycommon.StreamEndReasonEOF, OutcomeIgnored},
|
||||
{"client cancel", (*relaycommon.StreamStatus).MarkCancelled, relaycommon.StreamEndReasonEOF, OutcomeIgnored},
|
||||
{"client gone", nil, relaycommon.StreamEndReasonClientGone, OutcomeIgnored},
|
||||
{"timeout", nil, relaycommon.StreamEndReasonTimeout, OutcomeFailure},
|
||||
{"missing terminal", nil, relaycommon.StreamEndReasonEOF, OutcomeFailure},
|
||||
{"done marker without terminal", nil, relaycommon.StreamEndReasonDone, OutcomeSuccess},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stream := relaycommon.NewStreamStatus()
|
||||
stream.RequireTerminal()
|
||||
if tc.mark != nil {
|
||||
tc.mark(stream)
|
||||
}
|
||||
stream.SetEndReason(tc.end, nil)
|
||||
assert.Equal(t, tc.want, ClassifyRelayOutcome(context.Background(), &relaycommon.RelayInfo{StreamStatus: stream}, nil))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCancellationDuringUpstreamRead(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
stream := relaycommon.NewStreamStatus()
|
||||
stream.RequireTerminal()
|
||||
stream.SetEndReason(relaycommon.StreamEndReasonScannerErr, errors.New("reader closed"))
|
||||
assert.Equal(t, OutcomeIgnored, ClassifyRelayOutcome(ctx, &relaycommon.RelayInfo{StreamStatus: stream}, nil))
|
||||
|
||||
deadline := relaycommon.NewStreamStatus()
|
||||
deadline.SetEndReason(relaycommon.StreamEndReasonClientGone, context.DeadlineExceeded)
|
||||
assert.Equal(t, OutcomeFailure, ClassifyRelayOutcome(context.Background(), &relaycommon.RelayInfo{StreamStatus: deadline}, nil))
|
||||
}
|
||||
|
||||
func TestPerformanceWindowIncludesCurrentHour(t *testing.T) {
|
||||
now := time.Date(2026, 9, 14, 12, 37, 0, 0, time.UTC)
|
||||
start, end := queryWindow(now, 24)
|
||||
assert.Equal(t, time.Date(2026, 9, 13, 13, 0, 0, 0, time.UTC).Unix(), start)
|
||||
assert.Equal(t, now.Unix(), end)
|
||||
}
|
||||
|
||||
func TestHourlySuccessSeriesWeightsSmallerBuckets(t *testing.T) {
|
||||
points := recentSuccessSeries(map[int64]counters{
|
||||
3600: {requestCount: 100, successCount: 100},
|
||||
3900: {requestCount: 1},
|
||||
7200: {requestCount: 1, successCount: 1},
|
||||
})
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: 3600, SuccessRate: 99.01}, {Ts: 7200, SuccessRate: 100}}, points)
|
||||
}
|
||||
|
||||
// TEST_PERF_MYSQL_DSN / TEST_PERF_POSTGRES_DSN optionally run the aggregation
|
||||
// against isolated real MySQL/PostgreSQL databases.
|
||||
func TestPerformanceAggregationAndFlush(t *testing.T) {
|
||||
for _, dialect := range []struct{ name, env string }{
|
||||
{"sqlite", ""}, {"mysql", "TEST_PERF_MYSQL_DSN"}, {"postgres", "TEST_PERF_POSTGRES_DSN"},
|
||||
} {
|
||||
t.Run(dialect.name, func(t *testing.T) {
|
||||
dsn := ""
|
||||
if dialect.env != "" {
|
||||
dsn = os.Getenv(dialect.env)
|
||||
if dsn == "" {
|
||||
t.Skip("isolated test database DSN is not configured")
|
||||
}
|
||||
}
|
||||
t.Setenv("SQL_DSN", dsn)
|
||||
oldDB, oldPath, oldMaster, oldRedis := model.DB, common.SQLitePath, common.IsMasterNode, common.RedisEnabled
|
||||
oldType, oldLogType := common.MainDatabaseType(), common.LogDatabaseType()
|
||||
common.SQLitePath, common.IsMasterNode, common.RedisEnabled = filepath.Join(t.TempDir(), "perf.db"), false, false
|
||||
hotBuckets.Clear()
|
||||
t.Cleanup(func() {
|
||||
model.DB, common.SQLitePath, common.IsMasterNode, common.RedisEnabled = oldDB, oldPath, oldMaster, oldRedis
|
||||
common.SetDatabaseTypes(oldType, oldLogType)
|
||||
hotBuckets.Clear()
|
||||
})
|
||||
require.NoError(t, model.InitDB())
|
||||
db := model.DB
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
|
||||
require.NoError(t, db.Migrator().DropTable(&model.PerfMetric{}))
|
||||
require.NoError(t, db.AutoMigrate(&model.PerfMetric{}))
|
||||
|
||||
now := time.Now()
|
||||
start, _ := queryWindow(now, 24)
|
||||
hour := now.Unix() - now.Unix()%3600 - 3600
|
||||
// Historical counters remain usable without reclassification or migration.
|
||||
for _, row := range []model.PerfMetric{
|
||||
{ModelName: "test-model", Group: "a", BucketTs: hour, RequestCount: 100, SuccessCount: 100, TotalLatencyMs: 100000, TtftCount: 100, TtftSumMs: 10000, OutputTokens: 200, GenerationMs: 40000},
|
||||
{ModelName: "test-model", Group: "inactive", BucketTs: hour, RequestCount: 100},
|
||||
{ModelName: "test-model", Group: "a", BucketTs: start - 3600, RequestCount: 100},
|
||||
} {
|
||||
require.NoError(t, model.UpsertPerfMetric(&row))
|
||||
}
|
||||
groups := []string{"a", "b"}
|
||||
|
||||
RecordRelayResult(context.Background(), &relaycommon.RelayInfo{OriginModelName: "test-model", UsingGroup: "b", StartTime: now}, types.InitOpenAIError("unknown", 400))
|
||||
businessRejected, err := QuerySummaryAll(24, groups)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, businessRejected.Summary)
|
||||
assert.Equal(t, 100.0, businessRejected.Summary.SuccessRate)
|
||||
|
||||
failure := &atomicBucket{}
|
||||
failure.add(Sample{LatencyMs: 2000})
|
||||
hotBuckets.Store(bucketKey{model: "test-model", group: "b", bucketTs: hour}, failure)
|
||||
before, err := Query(QueryParams{Model: "test-model", Hours: 24, AllowedGroups: groups})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, before.Summary)
|
||||
assert.Equal(t, Summary{SuccessRate: 99.01, AvgLatencyMs: 1009, AvgTps: 5}, *before.Summary)
|
||||
assert.Equal(t, start, before.WindowStart)
|
||||
require.Len(t, before.Series, 1)
|
||||
assert.Equal(t, hour, before.Series[0].Ts)
|
||||
assert.InDelta(t, 99.01, before.Series[0].SuccessRate, 0.01)
|
||||
require.Len(t, before.Groups, 2)
|
||||
|
||||
summary, err := QuerySummaryAll(24, groups)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before.Summary, summary.Summary)
|
||||
require.Len(t, summary.Models, 1)
|
||||
assert.Equal(t, 99.01, summary.Models[0].SuccessRate)
|
||||
assert.Equal(t, 99.01, summary.Models[0].RecentSuccessSeries[0].SuccessRate)
|
||||
encoded, err := common.Marshal(summary)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(encoded), "request_count")
|
||||
assert.NotContains(t, string(encoded), "success_count")
|
||||
|
||||
flushCompletedBuckets()
|
||||
flushCompletedBuckets()
|
||||
after, err := Query(QueryParams{Model: "test-model", Hours: 24, AllowedGroups: groups})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before.Summary, after.Summary)
|
||||
assert.Equal(t, before.Series, after.Series)
|
||||
|
||||
RecordRelayResult(context.Background(), &relaycommon.RelayInfo{OriginModelName: "test-model", UsingGroup: "a", StartTime: now}, types.InitOpenAIError("context_length_exceeded", 400))
|
||||
after, err = Query(QueryParams{Model: "test-model", Hours: 24, AllowedGroups: groups})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before.Summary, after.Summary)
|
||||
onlyA, err := Query(QueryParams{Model: "test-model", Group: "a", Hours: 24, AllowedGroups: groups})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 100.0, onlyA.Summary.SuccessRate)
|
||||
empty, err := Query(QueryParams{Model: "missing", Hours: 24, AllowedGroups: groups})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, empty.Summary)
|
||||
assert.Empty(t, empty.Series)
|
||||
|
||||
require.NoError(t, model.UpsertPerfMetric(&model.PerfMetric{ModelName: "second-model", Group: "a", BucketTs: hour, RequestCount: 1}))
|
||||
combined, err := QuerySummaryAll(24, groups)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 98.04, combined.Summary.SuccessRate)
|
||||
assert.Equal(t, 99.01, combined.Models[0].SuccessRate)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package perfmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeSuccess Outcome = "success"
|
||||
OutcomeFailure Outcome = "failure"
|
||||
OutcomeIgnored Outcome = "ignored"
|
||||
)
|
||||
|
||||
// ClassifyRelayOutcome decides whether one finished relay counts as a health
|
||||
// sample. Business rejections and client cancellations are not samples; the
|
||||
// classification is independent of retries, channel disabling and billing.
|
||||
func ClassifyRelayOutcome(ctx context.Context, info *relaycommon.RelayInfo, apiErr *types.NewAPIError) Outcome {
|
||||
if info == nil || info.PerformanceBusinessRejection {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
if ctx != nil && ctx.Err() == context.Canceled {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
if apiErr != nil && errors.Is(apiErr, context.Canceled) {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
stream := info.StreamStatus.OutcomeSnapshot()
|
||||
if stream.Response == relaycommon.ResponseOutcomeFailed {
|
||||
return classifyFailure(false, stream.ErrorCode, stream.ErrorType, stream.ErrorStatus)
|
||||
}
|
||||
if apiErr != nil {
|
||||
root := rootAPIError(apiErr)
|
||||
local := root.GetErrorType() == types.ErrorTypeNewAPIError
|
||||
return classifyFailure(local, string(root.GetErrorCode()), root.ToOpenAIError().Type, root.StatusCode)
|
||||
}
|
||||
deadlineExceeded := info.StreamStatus != nil && errors.Is(info.StreamStatus.EndError, context.DeadlineExceeded)
|
||||
if stream.Response == relaycommon.ResponseOutcomeCancelled || stream.EndReason == relaycommon.StreamEndReasonPingFail {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
if stream.EndReason == relaycommon.StreamEndReasonClientGone && !deadlineExceeded {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
if stream.Response == relaycommon.ResponseOutcomeIncomplete {
|
||||
switch stream.IncompleteReason {
|
||||
case "max_output_tokens", "max_tokens":
|
||||
return OutcomeSuccess
|
||||
case "content_filter", "safety", "content_policy_violation":
|
||||
return OutcomeIgnored
|
||||
default:
|
||||
return OutcomeFailure
|
||||
}
|
||||
}
|
||||
if stream.HasErrors || deadlineExceeded {
|
||||
return OutcomeFailure
|
||||
}
|
||||
switch stream.EndReason {
|
||||
case relaycommon.StreamEndReasonTimeout, relaycommon.StreamEndReasonScannerErr, relaycommon.StreamEndReasonPanic:
|
||||
return OutcomeFailure
|
||||
}
|
||||
if stream.ExpectsTerminal && stream.Response == relaycommon.ResponseOutcomeUnknown && stream.EndReason != relaycommon.StreamEndReasonDone {
|
||||
return OutcomeFailure
|
||||
}
|
||||
return OutcomeSuccess
|
||||
}
|
||||
|
||||
// rootAPIError follows host wrappers back to the error that describes what
|
||||
// actually happened, e.g. an upstream credential error re-wrapped as a local
|
||||
// invalid request.
|
||||
func rootAPIError(apiErr *types.NewAPIError) *types.NewAPIError {
|
||||
for {
|
||||
var inner *types.NewAPIError
|
||||
if !errors.As(apiErr.Unwrap(), &inner) || inner == apiErr {
|
||||
return apiErr
|
||||
}
|
||||
apiErr = inner
|
||||
}
|
||||
}
|
||||
|
||||
func classifyFailure(local bool, code, errorType string, status int) Outcome {
|
||||
code, errorType = strings.ToLower(code), strings.ToLower(errorType)
|
||||
if local {
|
||||
if strings.HasPrefix(code, "violation_fee.") {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
switch types.ErrorCode(code) {
|
||||
case types.ErrorCodeInvalidRequest, types.ErrorCodeSensitiveWordsDetected, types.ErrorCodeReadRequestBodyFailed,
|
||||
types.ErrorCodeConvertRequestFailed, types.ErrorCodeAccessDenied, types.ErrorCodeBadRequestBody,
|
||||
types.ErrorCodeInsufficientUserQuota, types.ErrorCodePreConsumeTokenQuotaFailed, types.ErrorCodePromptBlocked:
|
||||
return OutcomeIgnored
|
||||
}
|
||||
return OutcomeFailure
|
||||
}
|
||||
// Specific codes take precedence over broad protocol types, because
|
||||
// providers also report invalid credentials as invalid_request_error.
|
||||
for _, value := range []string{code, errorType} {
|
||||
switch value {
|
||||
case "invalid_api_key", "api_key_invalid", "api_key_expired", "api_key_service_blocked", "invalid_authentication",
|
||||
"authentication_error", "unauthenticated", "permission_denied", "permission_error", "access_denied",
|
||||
"insufficient_quota", "quota_exceeded", "resource_exhausted", "rate_limit_exceeded", "rate_limit_error",
|
||||
"overloaded_error", "server_error", "internal_error", "service_unavailable", "model_not_found",
|
||||
"insufficient_user_quota", "pre_consume_token_quota_failed":
|
||||
return OutcomeFailure
|
||||
case "context_length_exceeded", "invalid_request", "invalid_request_error", "invalid_argument",
|
||||
"sensitive_words_detected", "prompt_blocked", "content_filter", "content_policy_violation", "safety":
|
||||
return OutcomeIgnored
|
||||
}
|
||||
if strings.HasPrefix(value, "violation_fee.") {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
}
|
||||
switch status {
|
||||
case 400, 405, 409, 413, 415, 422:
|
||||
return OutcomeIgnored
|
||||
}
|
||||
return OutcomeFailure
|
||||
}
|
||||
@@ -22,6 +22,16 @@ type QueryParams struct {
|
||||
Model string
|
||||
Group string
|
||||
Hours int
|
||||
// AllowedGroups restricts both the per-group results and the model summary;
|
||||
// nil allows every group.
|
||||
AllowedGroups []string
|
||||
}
|
||||
|
||||
// Summary is the request-weighted aggregate over every bucket in the window.
|
||||
type Summary struct {
|
||||
AvgLatencyMs int64 `json:"avg_latency_ms"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
AvgTps float64 `json:"avg_tps"`
|
||||
}
|
||||
|
||||
type BucketPoint struct {
|
||||
@@ -45,6 +55,10 @@ type GroupResult struct {
|
||||
type QueryResult struct {
|
||||
ModelName string `json:"model_name"`
|
||||
SeriesSchema string `json:"series_schema"`
|
||||
Summary *Summary `json:"summary"`
|
||||
Series []BucketPoint `json:"series"`
|
||||
WindowStart int64 `json:"window_start"`
|
||||
WindowEnd int64 `json:"window_end"`
|
||||
Groups []GroupResult `json:"groups"`
|
||||
}
|
||||
|
||||
@@ -63,7 +77,10 @@ type ModelSummary struct {
|
||||
}
|
||||
|
||||
type SummaryAllResult struct {
|
||||
Models []ModelSummary `json:"models"`
|
||||
Summary *Summary `json:"summary"`
|
||||
WindowStart int64 `json:"window_start"`
|
||||
WindowEnd int64 `json:"window_end"`
|
||||
Models []ModelSummary `json:"models"`
|
||||
}
|
||||
|
||||
type bucketKey struct {
|
||||
|
||||
Reference in new issue
Block a user