feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+2 -3
View File
@@ -1,7 +1,6 @@
package perfmetrics
import (
"fmt"
"strconv"
"time"
@@ -52,7 +51,7 @@ func flushCompletedBuckets() {
})
if err != nil {
bucket.addCounters(drained)
common.SysError(fmt.Sprintf("failed to flush perf metric bucket model=%s group=%s bucket=%d: %s", k.model, k.group, k.bucketTs, err.Error()))
common.SysError(common.LogText("failed to flush perf metric bucket model=%s group=%s bucket=%d: %s", k.model, k.group, k.bucketTs, err.Error()))
return true
}
@@ -73,7 +72,7 @@ func cleanupExpiredMetrics(retentionDays int) {
}
cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour).Unix()
if err := model.DeletePerfMetricsBefore(cutoff); err != nil {
common.SysError("failed to cleanup expired perf metrics: " + err.Error())
common.SysError(common.LogText("failed to cleanup expired perf metrics: %s", err.Error()))
}
}
+108 -8
View File
@@ -1,6 +1,7 @@
package perfmetrics
import (
"cmp"
"context"
"fmt"
"math"
@@ -23,6 +24,11 @@ var hotBuckets sync.Map
// hiding fields or making response-only privacy hardening changes.
const seriesSchema = "dbcd0a3c01b55203"
// MaxRecentModels bounds the historical lookups made by one summary request.
const MaxRecentModels = 50
const recentSuccessSampleLimit = 24
const recentSuccessHistoryHours = 7 * 24
func Init() {
go flushLoop()
}
@@ -67,6 +73,41 @@ func RecordRelayResult(ctx context.Context, info *relaycommon.RelayInfo, apiErr
})
}
// RecordTaskResult samples one async task exactly once, at its terminal
// transition, after the polling status CAS is won. Latency is the end-to-end
// task duration (second resolution, unlike the ms-resolution relay samples);
// throughput is only present when the provider reports tokens.
func RecordTaskResult(task *model.Task, result *relaycommon.TaskInfo) {
if task == nil {
return
}
modelName := task.Properties.OriginModelName
if bc := task.PrivateData.BillingContext; bc != nil && bc.OriginModelName != "" {
modelName = bc.OriginModelName
}
endTs := task.FinishTime
if endTs <= 0 {
endTs = time.Now().Unix()
}
sample := Sample{
Model: modelName,
Group: task.Group,
Success: task.Status == model.TaskStatusSuccess,
}
if task.SubmitTime > 0 && endTs > task.SubmitTime {
sample.LatencyMs = (endTs - task.SubmitTime) * 1000
}
if sample.Success && result != nil {
tokens := int64(cmp.Or(result.TotalTokens, result.CompletionTokens))
genStart := cmp.Or(task.StartTime, task.SubmitTime)
if tokens > 0 && genStart > 0 && endTs > genStart {
sample.OutputTokens = tokens
sample.GenerationMs = (endTs - genStart) * 1000
}
}
Record(sample)
}
func Record(sample Sample) {
setting := perf_metrics_setting.GetSetting()
if !setting.Enabled || sample.Model == "" {
@@ -154,16 +195,43 @@ func Query(params QueryParams) (QueryResult, error) {
return result, nil
}
func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
startTs, endTs := queryWindow(time.Now(), hours)
func QuerySummaryAll(hours int, groups []string, recentModels ...string) (SummaryAllResult, error) {
if len(recentModels) > MaxRecentModels {
return SummaryAllResult{}, common.NewMessage("Invalid parameters")
}
now := time.Now()
startTs, endTs := queryWindow(now, hours)
recentStartTs, _ := queryWindow(now, recentSuccessHistoryHours)
allowedGroups := allowedGroupSet(groups)
queryStartTs := startTs
rows, err := model.GetPerfMetricsSummaryBucketsAll(startTs, endTs, groups)
recentBuckets := make(map[string]map[int64]counters, len(recentModels))
for _, name := range recentModels {
if name == "" {
continue
}
if _, exists := recentBuckets[name]; exists {
continue
}
recentBuckets[name] = map[int64]counters{}
buckets, err := model.GetRecentPerfMetricSuccessBuckets(name, recentStartTs, endTs, groups, recentSuccessSampleLimit)
if err != nil {
return SummaryAllResult{}, err
}
for _, bucket := range buckets {
queryStartTs = min(queryStartTs, bucket.BucketTs)
mergeModelBucket(recentBuckets, name, bucket.BucketTs, counters{
requestCount: bucket.RequestCount,
successCount: bucket.SuccessCount,
})
}
}
rows, err := model.GetPerfMetricsSummaryBucketsAll(queryStartTs, endTs, groups)
if err != nil {
return SummaryAllResult{}, err
}
totals := map[string]counters{}
modelBuckets := map[string]map[int64]counters{}
for _, row := range rows {
value := counters{
@@ -173,13 +241,12 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
outputTokens: row.OutputTokens,
generationMs: row.GenerationMs,
}
mergeModelTotals(totals, row.ModelName, value)
mergeModelBucket(modelBuckets, row.ModelName, row.BucketTs, value)
}
hotBuckets.Range(func(key, value any) bool {
k := key.(bucketKey)
if k.bucketTs < startTs || k.bucketTs > endTs {
if k.bucketTs > endTs {
return true
}
if allowedGroups != nil {
@@ -191,11 +258,41 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
if snap.requestCount == 0 {
return true
}
mergeModelTotals(totals, k.model, snap)
if _, requested := recentBuckets[k.model]; requested && k.bucketTs >= recentStartTs {
mergeModelBucket(recentBuckets, k.model, k.bucketTs, snap)
}
if k.bucketTs < queryStartTs {
return true
}
mergeModelBucket(modelBuckets, k.model, k.bucketTs, snap)
return true
})
recentSeries := make(map[string][]SuccessRatePoint, len(recentBuckets))
for name, buckets := range recentBuckets {
points := recentSuccessSeries(buckets)
if len(points) == 0 {
continue
}
recentSeries[name] = points[max(0, len(points)-recentSuccessSampleLimit):]
}
// Requested models are summarized over the same hours as their status samples.
totals := map[string]counters{}
for name, buckets := range modelBuckets {
fromTs := startTs
if points, ok := recentSeries[name]; ok {
fromTs = points[0].Ts
}
for bucketTs, value := range buckets {
if bucketTs < fromTs {
delete(buckets, bucketTs)
continue
}
mergeModelTotals(totals, name, value)
}
}
all := counters{}
models := make([]ModelSummary, 0, len(totals))
for name, total := range totals {
@@ -226,7 +323,10 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
return models[i].RequestCount > models[j].RequestCount
})
return SummaryAllResult{Summary: summarize(all), WindowStart: startTs, WindowEnd: endTs, Models: models}, nil
return SummaryAllResult{
Summary: summarize(all), WindowStart: startTs, WindowEnd: endTs,
Models: models, RecentSuccessSeries: recentSeries,
}, nil
}
// QueryGroups combines raw bucket counters so busy models carry their actual weight.
+139
View File
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -109,6 +110,52 @@ func TestHourlySuccessSeriesWeightsSmallerBuckets(t *testing.T) {
assert.Equal(t, []SuccessRatePoint{{Ts: 3600, SuccessRate: 99.01}, {Ts: 7200, SuccessRate: 100}}, points)
}
func TestRecentHistoryRejectsOversizedModelLists(t *testing.T) {
_, err := QuerySummaryAll(24, nil, make([]string, 51)...)
require.EqualError(t, err, "Invalid parameters")
}
// Terminal task sampling: success/failure counts, end-to-end latency, and
// token throughput only for successful tasks that report tokens.
func TestRecordTaskResultSamplesTerminalTasks(t *testing.T) {
hotBuckets.Clear()
t.Cleanup(func() { hotBuckets.Clear() })
now := time.Now().Unix()
RecordTaskResult(&model.Task{
Status: model.TaskStatusSuccess,
Group: "a",
SubmitTime: now - 120,
StartTime: now - 100,
FinishTime: now,
Properties: model.Properties{OriginModelName: "video-model"},
}, &relaycommon.TaskInfo{TotalTokens: 5000})
RecordTaskResult(&model.Task{
Status: model.TaskStatusFailure,
Group: "a",
SubmitTime: now - 60,
FinishTime: now,
Properties: model.Properties{OriginModelName: "video-model"},
}, relaycommon.FailTaskInfo("boom"))
RecordTaskResult(&model.Task{Status: model.TaskStatusSuccess, FinishTime: now}, nil)
merged := map[bucketKey]counters{}
hotBuckets.Range(func(key, value any) bool {
k := key.(bucketKey)
require.Equal(t, "video-model", k.model)
require.Equal(t, "a", k.group)
k.bucketTs = 0
mergeCounters(merged, k, value.(*atomicBucket).snapshot())
return true
})
assert.Equal(t, counters{
requestCount: 2,
successCount: 1,
totalLatencyMs: 180000,
outputTokens: 5000,
generationMs: 100000,
}, merged[bucketKey{model: "video-model", group: "a"}])
}
// TEST_PERF_MYSQL_DSN / TEST_PERF_POSTGRES_DSN optionally run the aggregation
// against isolated real MySQL/PostgreSQL databases.
func TestPerformanceAggregationAndFlush(t *testing.T) {
@@ -140,6 +187,13 @@ func TestPerformanceAggregationAndFlush(t *testing.T) {
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
require.NoError(t, db.Migrator().DropTable(&model.PerfMetric{}))
require.NoError(t, db.AutoMigrate(&model.PerfMetric{}))
versionQuery := "SELECT version()"
if dialect.name == "sqlite" {
versionQuery = "SELECT sqlite_version()"
}
var version string
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
t.Logf("database version: %s", version)
now := time.Now()
start, _ := queryWindow(now, 24)
@@ -208,6 +262,91 @@ func TestPerformanceAggregationAndFlush(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 98.04, combined.Summary.SuccessRate)
assert.Equal(t, 99.01, combined.Models[0].SuccessRate)
t.Run("recent models are summarized over their status sample hours", func(t *testing.T) {
for i := range 26 {
require.NoError(t, model.UpsertPerfMetric(&model.PerfMetric{
ModelName: "history-model", Group: "a", BucketTs: hour - int64(i+1)*6*3600,
RequestCount: 1, SuccessCount: 1,
}))
}
for _, row := range []model.PerfMetric{
{ModelName: "history-model", Group: "a", BucketTs: hour + 60, RequestCount: 100, SuccessCount: 100},
{ModelName: "history-model", Group: "inactive", BucketTs: hour + 120, RequestCount: 100},
{ModelName: "history-model", Group: "a", BucketTs: hour + 2*3600, RequestCount: 1},
{ModelName: "history-idle", Group: "a", BucketTs: hour - 3*24*3600, RequestCount: 1},
{ModelName: "history-hidden", Group: "inactive", BucketTs: hour - 3*24*3600, RequestCount: 1},
} {
require.NoError(t, model.UpsertPerfMetric(&row))
}
require.NoError(t, db.Create(&model.PerfMetric{ModelName: "history-model", Group: "a", BucketTs: hour - 3600}).Error)
hotFailure := &atomicBucket{}
hotFailure.add(Sample{})
hotBuckets.Store(bucketKey{model: "history-model", group: "b", bucketTs: hour + 120}, hotFailure)
hotSuccess := &atomicBucket{}
hotSuccess.add(Sample{Success: true})
hotBuckets.Store(bucketKey{model: "history-hot", group: "a", bucketTs: hour}, hotSuccess)
hiddenFailure := &atomicBucket{}
hiddenFailure.add(Sample{})
hotBuckets.Store(bucketKey{model: "history-hidden", group: "inactive", bucketTs: hour}, hiddenFailure)
window, err := QuerySummaryAll(24, groups)
require.NoError(t, err)
history, err := QuerySummaryAll(24, groups, "history-model", "history-idle", "history-hidden", "history-hot", "missing")
require.NoError(t, err)
assert.Equal(t, window.WindowStart, history.WindowStart)
summaries := lo.KeyBy(history.Models, func(m ModelSummary) string { return m.ModelName })
assert.Equal(t, int64(124), summaries["history-model"].RequestCount)
assert.Equal(t, 99.19, summaries["history-model"].SuccessRate)
assert.Equal(t, int64(1), summaries["history-idle"].RequestCount)
assert.Equal(t, 0.0, summaries["history-idle"].SuccessRate)
assert.NotContains(t, summaries, "history-hidden")
points := history.RecentSuccessSeries["history-model"]
require.Len(t, points, 24)
expected := make([]SuccessRatePoint, 0, 24)
for i := 23; i > 0; i-- {
expected = append(expected, SuccessRatePoint{Ts: hour - int64(i)*6*3600, SuccessRate: 100})
}
expected = append(expected, SuccessRatePoint{Ts: hour, SuccessRate: 99.01})
assert.Equal(t, expected, points)
assert.Equal(t, []SuccessRatePoint{{Ts: hour - 3*24*3600, SuccessRate: 0}}, history.RecentSuccessSeries["history-idle"])
assert.Equal(t, []SuccessRatePoint{{Ts: hour, SuccessRate: 100}}, history.RecentSuccessSeries["history-hot"])
assert.Empty(t, history.RecentSuccessSeries["history-hidden"])
assert.Empty(t, history.RecentSuccessSeries["missing"])
denied, err := QuerySummaryAll(24, []string{}, "history-model", "history-hidden")
require.NoError(t, err)
assert.Empty(t, denied.RecentSuccessSeries)
flushCompletedBuckets()
persisted, err := QuerySummaryAll(24, groups, "history-model", "history-hot")
require.NoError(t, err)
assert.Equal(t, points, persisted.RecentSuccessSeries["history-model"])
assert.Equal(t, history.RecentSuccessSeries["history-hot"], persisted.RecentSuccessSeries["history-hot"])
})
t.Run("recent history stops at the 7 day boundary even with fewer than 24 samples", func(t *testing.T) {
historyStart, _ := queryWindow(now, 7*24)
for _, row := range []model.PerfMetric{
{ModelName: "history-boundary", Group: "a", BucketTs: historyStart, RequestCount: 1, SuccessCount: 1},
{ModelName: "history-boundary", Group: "a", BucketTs: historyStart - 3600, RequestCount: 1},
{ModelName: "history-expired", Group: "a", BucketTs: historyStart - 3600, RequestCount: 1},
} {
require.NoError(t, model.UpsertPerfMetric(&row))
}
hotExpired := &atomicBucket{}
hotExpired.add(Sample{Success: true})
hotBuckets.Store(bucketKey{model: "history-hot-expired", group: "a", bucketTs: historyStart - 3600}, hotExpired)
hotBoundary := &atomicBucket{}
hotBoundary.add(Sample{Success: true})
hotBuckets.Store(bucketKey{model: "history-hot-boundary", group: "a", bucketTs: historyStart}, hotBoundary)
history, err := QuerySummaryAll(24, groups, "history-boundary", "history-expired", "history-hot-expired", "history-hot-boundary")
require.NoError(t, err)
assert.Equal(t, []SuccessRatePoint{{Ts: historyStart, SuccessRate: 100}}, history.RecentSuccessSeries["history-boundary"])
assert.Equal(t, []SuccessRatePoint{{Ts: historyStart, SuccessRate: 100}}, history.RecentSuccessSeries["history-hot-boundary"])
assert.Empty(t, history.RecentSuccessSeries["history-expired"])
assert.Empty(t, history.RecentSuccessSeries["history-hot-expired"])
})
})
}
}
+10 -4
View File
@@ -18,19 +18,25 @@ const (
)
// 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.
// sample. Business rejections and cancellations during generation are not
// samples; a stream already completed normally without errors remains eligible
// after its context is canceled. 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 {
stream := info.StreamStatus.OutcomeSnapshot()
completedNormally := info.IsStream && info.StreamStatus != nil && apiErr == nil &&
stream.Response == relaycommon.ResponseOutcomeCompleted &&
(stream.EndReason == relaycommon.StreamEndReasonEOF || stream.EndReason == relaycommon.StreamEndReasonDone) &&
!stream.HasErrors && info.StreamStatus.EndError == nil
if ctx != nil && ctx.Err() == context.Canceled && !completedNormally {
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)
}
+3
View File
@@ -81,6 +81,9 @@ type SummaryAllResult struct {
WindowStart int64 `json:"window_start"`
WindowEnd int64 `json:"window_end"`
Models []ModelSummary `json:"models"`
// RecentSuccessSeries contains up to 24 nonempty hours from the last 7 days,
// including requested models with no traffic in the summary window.
RecentSuccessSeries map[string][]SuccessRatePoint `json:"recent_success_series,omitempty"`
}
type bucketKey struct {