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

This commit is contained in:
cneicy committed 2026-10-06 00:07:10 +08:00
1 parent d09216bbde
commit 8e6ed0317b
154 files changed
+39540 -20509

No files matched your search

+32 -17
View File
@@ -3,10 +3,7 @@ package controller
import (
"bytes"
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"sync"
@@ -15,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
benchmark "github.com/QuantumNous/new-api/pkg/model_benchmark"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -97,7 +95,7 @@ func (astraSamplingHandler) Run(parent context.Context, task *model.SystemTask,
if loadErr != nil || channel.Status != common.ChannelStatusEnabled {
reason = "channel_unavailable"
} else {
for probe := 0; probe < 3 && ctx.Err() == nil; probe++ {
for probe := 0; probe < fingerprint.ProbeCount && ctx.Err() == nil; probe++ {
mu.Lock()
state.Rounds[index].Sent++
ok = persist()
@@ -105,7 +103,7 @@ func (astraSamplingHandler) Run(parent context.Context, task *model.SystemTask,
if !ok {
break
}
answer, count, probeErr := runAstraFingerprintProbe(ctx, channel)
answer, count, probeErr := runAstraFingerprintProbe(ctx, channel, probe)
usage = append(usage, answer)
if probeErr != nil {
if errors.Is(probeErr, fingerprint.ErrInvalidSample) {
@@ -142,7 +140,7 @@ func (astraSamplingHandler) Run(parent context.Context, task *model.SystemTask,
state.Rounds[index].Usage = usage
state.Rounds[index].FinishedAt = time.Now().Unix()
if persist() && !payload.Force {
if err := model.ApplyDetectionScheduleResult(ctx, id, "fingerprint", state.Rounds[index].ID, state.Rounds[index].FinishedAt, service.FingerprintScheduleOutcome(service.AstraSamplingResultStatus(result))); err != nil {
if err := model.ApplyDetectionScheduleResult(ctx, id, "modeltrace", state.Rounds[index].ID, state.Rounds[index].FinishedAt, service.FingerprintScheduleOutcome(service.AstraSamplingResultStatus(result))); err != nil {
persistErr = err
cancel()
}
@@ -166,22 +164,39 @@ func (astraSamplingHandler) Run(parent context.Context, task *model.SystemTask,
finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, map[string]any{"channels": len(state.Rounds), "model": fingerprint.Model}, nil)
}
func runAstraFingerprintProbe(parent context.Context, channel *model.Channel, paid ...bool) (fingerprint.ProbeResponse, int, error) {
func runAstraFingerprintProbe(parent context.Context, channel *model.Channel, index int) (fingerprint.ProbeResponse, int, error) {
return runModelDetectionProbe(parent, channel, index, "fingerprint")
}
func runModelDetectionProbe(parent context.Context, channel *model.Channel, index int, kind string) (fingerprint.ProbeResponse, int, error) {
ctx, cancel := context.WithTimeout(parent, 180*time.Second)
defer cancel()
seed, err := rand.Int(rand.Reader, big.NewInt(82))
var probe fingerprint.Probe
var err error
if kind == "fingerprint" {
probe, err = fingerprint.ProbeAt(index)
}
if err != nil {
return fingerprint.ProbeResponse{}, 0, err
return fingerprint.ProbeResponse{}, index, err
}
count := 292 + int(seed.Int64()/2)
prompt := fmt.Sprintf("Directly choose %d integers from 1 through 355, inclusive. Allow repeats. Do not sort, balance frequencies, repair duplicates, or deliberately create an arithmetic pattern.\nReply directly with one JSON integer array and no explanation. Do not call tools, read files, execute code or ask another model. Do not continue the preceding task.", count)
if seed.Int64()%2 == 0 {
prompt = fmt.Sprintf("直接选择 %d 个 1 到 355(含边界)的整数。允许重复;不要排序、平衡频数、修复重复,也不要刻意构造等差规律。\n直接回答一个 JSON 整数数组,不要解释。不要调用工具、读文件、运行代码或让其他模型代答;不要继续之前的任务。", count)
count, output := probe.Count, fingerprint.OutputTokens
messages := []map[string]any{{"role": "user", "content": probe.Prompt}}
if kind == "benchmark" {
secondary, err := benchmark.ProbeAt(index)
if err != nil {
return fingerprint.ProbeResponse{}, index, err
}
count, output = index, benchmark.OutputTokens
messages = []map[string]any{{"role": "system", "content": secondary.System}, {"role": "user", "content": secondary.Prompt}}
}
input, _ := common.Marshal([]map[string]any{{"role": "user", "content": []map[string]string{{"type": "input_text", "text": prompt}}}})
if kind == "candy" {
count, output = 1, service.CandyOutputTokens
messages = []map[string]any{{"role": "user", "content": service.CandyPrompt}}
}
input, _ := common.Marshal(messages)
stream := true
limit := uint(4096)
request := &dto.OpenAIResponsesRequest{Model: fingerprint.Model, Input: input, Instructions: []byte(`""`), Stream: &stream, MaxOutputTokens: &limit, Store: []byte("false"), Tools: []byte("[]"), ToolChoice: []byte(`"none"`)}
limit := uint(output)
request := &dto.OpenAIResponsesRequest{Model: fingerprint.Model, Input: input, Stream: &stream, MaxOutputTokens: &limit, Store: []byte("false"), Reasoning: &dto.Reasoning{Effort: "low"}}
encoded, err := common.Marshal(request)
if err != nil {
return fingerprint.ProbeResponse{}, count, err
@@ -207,7 +222,7 @@ func runAstraFingerprintProbe(parent context.Context, channel *model.Channel, pa
if apiErr != nil {
return fingerprint.ProbeResponse{}, count, errors.New("probe preparation failed")
}
if len(paid) > 0 && paid[0] {
{
bounded, boundedCloser, boundErr := boundDetectionBody(body, request)
if boundErr != nil {
return fingerprint.ProbeResponse{}, count, boundErr
+12 -2
View File
@@ -5,11 +5,14 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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"
"github.com/tidwall/gjson"
)
@@ -17,6 +20,13 @@ func TestAstraSamplingUsesIsolatedResponsesWithoutGuard(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
bodyPreview, readErr := io.ReadAll(r.Body)
require.NoError(t, readErr)
r.Body = io.NopCloser(strings.NewReader(string(bodyPreview)))
assert.EqualValues(t, fingerprint.OutputTokens, gjson.GetBytes(bodyPreview, "max_output_tokens").Int())
assert.Equal(t, "low", gjson.GetBytes(bodyPreview, "reasoning.effort").String())
assert.Contains(t, gjson.GetBytes(bodyPreview, "input.0.content").String(), "355")
body, _ := io.ReadAll(r.Body)
if r.URL.Path != "/v1/responses" || gjson.GetBytes(body, "model").String() != fingerprint.Model || !gjson.GetBytes(body, "stream").Bool() {
t.Errorf("wrong probe request %s %s", r.URL.Path, body)
@@ -30,8 +40,8 @@ func TestAstraSamplingUsesIsolatedResponsesWithoutGuard(t *testing.T) {
defer server.Close()
url := server.URL
channel := &model.Channel{Id: 1, Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model}
r, count, err := runAstraFingerprintProbe(context.Background(), channel)
if err != nil || r.DeclaredModel != "gpt-5.6-luna" || count < 292 || count > 332 || requests != 1 {
r, count, err := runAstraFingerprintProbe(context.Background(), channel, 0)
if err != nil || r.DeclaredModel != "gpt-5.6-luna" || count < 292 || count > 304 || requests != 1 {
t.Fatalf("probe %+v count=%d requests=%d err=%v", r, count, requests, err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"testing"
)
func TestListModelsAutomaticPoolAllFormats(t *testing.T) {
db := setupModelListControllerTestDB(t)
withSelfUseModeDisabled(t)
oldGroups, oldRatios, oldModelRatios := setting.UserUsableGroups2JSONString(), ratio_setting.GroupRatio2JSONString(), ratio_setting.ModelRatio2JSONString()
oldMemory := common.MemoryCacheEnabled
common.MemoryCacheEnabled = false
t.Cleanup(func() {
common.MemoryCacheEnabled = oldMemory
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(oldGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(oldRatios))
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(oldModelRatios))
})
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"list-a":"","list-b":""}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"list-a":1,"list-b":1}`))
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"zz-list-a":1,"zz-list-b":1,"zz-shared":1,"zz-disabled":1}`))
require.NoError(t, db.Create(&[]model.Channel{{Id: 5601, Type: constant.ChannelTypeOpenAI, Status: 1}, {Id: 5602, Type: constant.ChannelTypeOpenAI, Status: 1}, {Id: 5603, Status: 2}}).Error)
require.NoError(t, db.Create(&[]model.Ability{
{ChannelId: 5601, Group: "list-a", Model: "zz-list-a", Enabled: true},
{ChannelId: 5602, Group: "list-b", Model: "zz-list-b", Enabled: true},
{ChannelId: 5601, Group: "list-a", Model: "zz-shared", Enabled: true},
{ChannelId: 5602, Group: "list-b", Model: "zz-shared", Enabled: true},
{ChannelId: 5603, Group: "list-b", Model: "zz-disabled", Enabled: true},
{ChannelId: 5602, Group: "list-b", Model: "zz-unpriced", Enabled: true},
}).Error)
for _, preference := range []string{"cost", "speed", "stable"} {
for _, limited := range []bool{false, true} {
for _, format := range []int{constant.ChannelTypeOpenAI, constant.ChannelTypeAnthropic, constant.ChannelTypeGemini} {
policy, err := common.Marshal(model.TokenRoutingPolicy{Version: 1, Mode: "smart", AutoPreference: preference})
require.NoError(t, err)
token := &model.Token{Group: "auto", RoutingPolicy: string(policy), ModelLimitsEnabled: limited, ModelLimits: "zz-list-b,zz-disabled,zz-unpriced"}
engine := gin.New()
engine.GET("/v1/models", func(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyUserGroup, "list-a")
require.NoError(t, middleware.SetupContextForToken(c, token))
ListModels(c, format)
})
res := httptest.NewRecorder()
engine.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/v1/models", nil))
require.Equal(t, 200, res.Code)
var body struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
require.NoError(t, common.Unmarshal(res.Body.Bytes(), &body))
names := []string{}
for _, item := range body.Data {
names = append(names, item.ID)
}
for _, item := range body.Models {
names = append(names, item.Name)
}
expected := []string{"zz-list-a", "zz-list-b", "zz-shared"}
if limited {
expected = []string{"zz-list-b"}
}
require.ElementsMatch(t, expected, names, "preference=%s limited=%v format=%d", preference, limited, format)
}
}
}
}
+3 -2
View File
@@ -239,6 +239,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
}
}
isStream = info.IsStream
info.IsChannelTest = true
info.InitChannelMeta(c)
@@ -666,7 +667,7 @@ func validateTestResponseBody(respBody []byte, isStream bool) error {
}
func shouldUseStreamForAutomaticChannelTest(channel *model.Channel) bool {
return channel != nil && channel.Type == constant.ChannelTypeCodex
return channel != nil
}
func detectErrorMessageFromJSONBytes(jsonBytes []byte) string {
@@ -863,7 +864,7 @@ func TestChannel(c *gin.Context) {
//}()
testModel := c.Query("model")
endpointType := c.Query("endpoint_type")
isStream, _ := strconv.ParseBool(c.Query("stream"))
isStream, _ := strconv.ParseBool(c.DefaultQuery("stream", "true"))
testUserID, err := resolveChannelTestUserID(c)
if err != nil {
common.ApiError(c, err)
+12 -13
View File
@@ -16,6 +16,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
benchmark "github.com/QuantumNous/new-api/pkg/model_benchmark"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -41,7 +42,7 @@ func detectionInput(c *gin.Context) (detectionRequest, *model.User, service.Dete
if err := c.ShouldBindJSON(&input); err != nil {
return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection request")
}
if input.Kind != "pelican" && input.Kind != "fingerprint" {
if input.Kind != "benchmark" && input.Kind != "fingerprint" {
return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection type")
}
user, err := model.GetUserById(c.GetInt("id"), false)
@@ -62,9 +63,9 @@ func EstimateDetection(c *gin.Context) {
return
}
encoded, _ := common.Marshal(price)
calls := 1
calls := benchmark.ProbeCount
if input.Kind == "fingerprint" {
calls = 3
calls = fingerprint.ProbeCount
}
c.JSON(200, gin.H{"success": true, "data": gin.H{"reserved": reserve, "ratio": price.Snapshot.GroupRatio, "calls": calls, "price_version": model.DetectionSecretHash(string(encoded))}})
}
@@ -267,7 +268,7 @@ func detectionProxyRequest(data []byte) (*dto.OpenAIResponsesRequest, error) {
return nil, errors.New("input too large")
}
// Service-selected bounds cannot be overridden by the bearer.
fields = map[string]any{"model": fingerprint.Model, "input": fields["input"], "instructions": instructions, "stream": true, "max_output_tokens": 16384, "reasoning": map[string]string{"effort": "high"}, "store": false, "tools": []any{}}
fields = map[string]any{"model": fingerprint.Model, "input": fields["input"], "instructions": instructions, "stream": true, "max_output_tokens": 16384, "reasoning": map[string]string{"effort": "medium"}, "store": false, "tools": []any{}}
encoded, _ := common.Marshal(fields)
var request dto.OpenAIResponsesRequest
err = common.Unmarshal(encoded, &request)
@@ -342,14 +343,6 @@ func DetectionProxy(c *gin.Context) {
}
}
}
if success {
if html := detectionHTML(raw, contentType); html != "" {
if saveErr := model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Update("html", html).Error; saveErr != nil {
call.Error = "artwork_save_failed"
success = false
}
}
}
if err = model.FinishDetectionCall(call); err != nil {
success = false
}
@@ -481,7 +474,13 @@ func boundDetectionBody(body common.ReplayableBody, request *dto.OpenAIResponses
prepared["instructions"] = instructions
prepared["max_output_tokens"] = *request.MaxOutputTokens
if *request.MaxOutputTokens == 16384 {
prepared["reasoning"] = map[string]string{"effort": "high"}
prepared["reasoning"] = map[string]string{"effort": "medium"}
}
if *request.MaxOutputTokens == fingerprint.OutputTokens || *request.MaxOutputTokens == benchmark.OutputTokens {
prepared["reasoning"] = map[string]string{"effort": "low"}
delete(prepared, "instructions")
delete(prepared, "temperature")
delete(prepared, "top_p")
}
prepared["tools"] = []any{}
prepared["tool_choice"] = "none"
+217
View File
@@ -2,6 +2,8 @@ package controller
import (
"bufio"
"context"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
@@ -11,10 +13,15 @@ import (
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
@@ -32,6 +39,10 @@ func TestDetectionProxyFlushesBeforeGenerationAndConsumesCredentialOnce(t *testi
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{}))
release := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, readErr := io.ReadAll(r.Body)
require.NoError(t, readErr)
assert.Equal(t, "medium", gjson.GetBytes(body, "reasoning.effort").String())
select {
case <-release:
case <-r.Context().Done():
@@ -169,3 +180,209 @@ func TestDetectionLatestPerChannelStatus(t *testing.T) {
})
}
}
func TestDetectionMeowPaidRoundAndResume(t *testing.T) {
oldRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = oldRedis })
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.DetectionRun{}, &model.DetectionCall{}, &model.User{}))
data, err := os.ReadFile("../pkg/model_fingerprint/testdata/meow-reference.json")
require.NoError(t, err)
var fixtures []struct {
Samples []fingerprint.Sample `json:"samples"`
}
require.NoError(t, common.Unmarshal(data, &fixtures))
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Less(t, calls, 32)
answer := fixtures[0].Samples[calls].Text
calls++
body, encodeErr := common.Marshal(map[string]any{"status": "completed", "usage": map[string]int{"input_tokens": 10, "output_tokens": 2}, "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": answer}}}}})
require.NoError(t, encodeErr)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
defer upstream.Close()
url := upstream.URL
channel := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model}
require.NoError(t, db.Create(&channel).Error)
owner := model.User{Username: "meow-test", Quota: 1000, Status: common.UserStatusEnabled}
require.NoError(t, db.Create(&owner).Error)
price, err := common.Marshal(service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: "p+c", ExprHash: billingexpr.ExprHashString("p+c"), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1}, Request: billingexpr.RequestInput{Body: []byte(`{"max_output_tokens":128,"reasoning":{"effort":"low"}}`)}})
require.NoError(t, err)
run := model.DetectionRun{ID: "meow-test", RequestKey: "meow-test", UserID: owner.Id, Kind: "benchmark", ChannelID: channel.Id, Status: "pending", Deadline: time.Now().Unix() + 60, Reserved: 320, Price: string(price)}
require.NoError(t, model.CreateDetection(&run))
processPaidFingerprint(context.Background(), run)
require.NoError(t, db.First(&run, "id = ?", run.ID).Error)
assert.Equal(t, "succeeded", run.Status)
assert.Equal(t, "normal", run.Quality)
assert.True(t, run.Settled)
assert.Equal(t, 32, calls)
assert.Equal(t, 192, run.Charged)
require.NoError(t, db.First(&owner, owner.Id).Error)
assert.Equal(t, 808, owner.Quota)
processPaidFingerprint(context.Background(), run)
assert.Equal(t, 32, calls, "resume must not resend or recharge")
require.NoError(t, db.First(&owner, owner.Id).Error)
assert.Equal(t, 808, owner.Quota)
}
func TestDetectionModelTracePaidRoundAndResume(t *testing.T) {
oldRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = oldRedis })
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.DetectionRun{}, &model.DetectionCall{}, &model.User{}))
data, err := os.ReadFile("../pkg/model_fingerprint/testdata/modeltrace-reference.json")
require.NoError(t, err)
var fixtures []struct {
Samples []fingerprint.Sample `json:"samples"`
}
require.NoError(t, common.Unmarshal(data, &fixtures))
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Less(t, calls, 3)
answer := fixtures[1].Samples[calls].Text
calls++
body, encodeErr := common.Marshal(map[string]any{"status": "completed", "usage": map[string]int{"input_tokens": 10, "output_tokens": 2}, "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": answer}}}}})
require.NoError(t, encodeErr)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
defer upstream.Close()
url := upstream.URL
channel := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model}
require.NoError(t, db.Create(&channel).Error)
owner := model.User{Username: "meow-test", Quota: 1000, Status: common.UserStatusEnabled}
require.NoError(t, db.Create(&owner).Error)
price, err := common.Marshal(service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: "p+c", ExprHash: billingexpr.ExprHashString("p+c"), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1}, Request: billingexpr.RequestInput{Body: []byte(`{"max_output_tokens":4096,"reasoning":{"effort":"low"}}`)}})
require.NoError(t, err)
run := model.DetectionRun{ID: "meow-test", RequestKey: "meow-test", UserID: owner.Id, Kind: "fingerprint", ChannelID: channel.Id, Status: "pending", Deadline: time.Now().Unix() + 60, Reserved: 320, Price: string(price)}
require.NoError(t, model.CreateDetection(&run))
processPaidFingerprint(context.Background(), run)
require.NoError(t, db.First(&run, "id = ?", run.ID).Error)
assert.Equal(t, "succeeded", run.Status)
assert.Equal(t, "match", run.Quality)
assert.True(t, run.Settled)
assert.Equal(t, 3, calls)
assert.Equal(t, 18, run.Charged)
require.NoError(t, db.First(&owner, owner.Id).Error)
assert.Equal(t, 982, owner.Quota)
processPaidFingerprint(context.Background(), run)
assert.Equal(t, 3, calls, "resume must not resend or recharge")
require.NoError(t, db.First(&owner, owner.Id).Error)
assert.Equal(t, 982, owner.Quota)
}
func TestCandyAutomaticRoundAndResume(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
oldRedis := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() { common.RedisEnabled = oldRedis })
var driver gorm.Dialector = sqlite.Open(":memory:")
if dialect == "mysql" {
if os.Getenv("TEST_MYSQL_DSN") == "" {
t.Skip("TEST_MYSQL_DSN unset")
}
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
}
if dialect == "postgres" {
if os.Getenv("TEST_POSTGRES_DSN") == "" {
t.Skip("TEST_POSTGRES_DSN unset")
}
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("candy_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
oldType, oldLogType := common.MainDatabaseType(), common.LogDatabaseType()
common.SetDatabaseTypes(common.DatabaseType(dialect), common.DatabaseType(dialect))
t.Cleanup(func() { common.SetDatabaseTypes(oldType, oldLogType) })
old := model.DB
model.DB = db
t.Cleanup(func() {
for _, row := range []any{&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.User{}, &model.DetectionSchedule{}, &model.SystemTask{}} {
require.NoError(t, db.Migrator().DropTable(row))
}
model.DB = old
_ = conn.Close()
})
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.User{}, &model.DetectionSchedule{}, &model.SystemTask{}))
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Less(t, calls, 1)
answer := "21"
raw, readErr := io.ReadAll(r.Body)
require.NoError(t, readErr)
assert.Contains(t, gjson.GetBytes(raw, "input.0.content").String(), "圆形 7 9 8")
assert.NotContains(t, gjson.GetBytes(raw, "input.0.content").String(), "21")
calls++
body, encodeErr := common.Marshal(map[string]any{"status": "completed", "usage": map[string]int{"input_tokens": 10, "output_tokens": 2}, "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": answer}}}}})
require.NoError(t, encodeErr)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
defer upstream.Close()
url := upstream.URL
channel := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model, Group: "candy-group"}
require.NoError(t, db.Create(&channel).Error)
owner := model.User{Username: "meow-test", Quota: 1000, Status: common.UserStatusEnabled}
require.NoError(t, db.Create(&owner).Error)
price, err := common.Marshal(service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: "p+c", ExprHash: billingexpr.ExprHashString("p+c"), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1}, Request: billingexpr.RequestInput{Body: []byte(`{"max_output_tokens":4096,"reasoning":{"effort":"low"}}`)}})
require.NoError(t, err)
run := model.DetectionRun{ID: "meow-test", RequestKey: "meow-test", Kind: "candy", CreatedAt: time.Now().Unix(), ChannelID: channel.Id, Status: "pending", Deadline: time.Now().Unix() + 60, Reserved: 320, Price: string(price)}
require.NoError(t, model.CreateDetection(&run))
require.NoError(t, model.EnsureDetectionSchedule(context.Background(), model.DetectionSchedule{ChannelID: channel.Id, Kind: "candy", IntervalMinutes: 30}))
processDetection(context.Background(), run)
require.NoError(t, db.First(&run, "id = ?", run.ID).Error)
assert.Equal(t, "succeeded", run.Status)
assert.Equal(t, "normal", run.Quality)
assert.True(t, run.Settled)
var clock model.DetectionSchedule
require.NoError(t, db.First(&clock, "channel_id = ? AND kind = ?", channel.Id, "candy").Error)
assert.Equal(t, "normal", clock.LastValid)
assert.Greater(t, clock.NextDueAt, run.FinishedAt)
assert.Equal(t, 1, calls)
assert.Equal(t, 0, run.Charged, "automatic tests do not charge a customer")
require.NoError(t, db.First(&owner, owner.Id).Error)
assert.Equal(t, 1000, owner.Quota)
processDetection(context.Background(), run)
assert.Equal(t, 1, calls, "resume must not resend or recharge")
require.NoError(t, db.First(&owner, owner.Id).Error)
assert.Equal(t, 1000, owner.Quota)
summaries, err := service.GroupDetectionSummaries(context.Background(), time.Now(), map[string]*service.AstraSamplingSummary{}, map[string]model.StatusPageGroup{})
require.NoError(t, err)
require.NotNil(t, summaries["candy-group"].Candy)
assert.Equal(t, 1, summaries["candy-group"].Candy.Normal)
assert.Equal(t, "21", summaries["candy-group"].Candy.History[0].Closest)
assert.Zero(t, summaries["candy-group"].Benchmark.Normal)
})
}
}
func TestCandyAnswers(t *testing.T) {
for _, tc := range []struct{ answer, quality string }{
{"21", "normal"}, {" 21\n", "normal"}, {"28", "degraded"}, {"121", "degraded"}, {"21或28", "degraded"}, {"答案不是21", "degraded"}, {"", "degraded"},
} {
t.Run(tc.answer, func(t *testing.T) { assert.Equal(t, tc.quality, service.CandyQuality(tc.answer)) })
}
}
+59 -187
View File
@@ -1,22 +1,15 @@
package controller
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
benchmark "github.com/QuantumNous/new-api/pkg/model_benchmark"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/service"
"github.com/tidwall/gjson"
@@ -24,15 +17,9 @@ import (
type detectionHandler struct{}
func pelicanCredential(id string) string {
mac := hmac.New(sha256.New, []byte(common.SessionSecret))
mac.Write([]byte("newapi-pelican-credential-v1:" + id))
return "det_" + hex.EncodeToString(mac.Sum(nil))
}
func (detectionHandler) Type() string { return "group_detection" }
func (detectionHandler) Enabled() bool {
return common.GetEnvOrDefaultBool("PELICAN_DETECTION_ENABLED", true)
return common.GetEnvOrDefaultBool("MODEL_DETECTION_ENABLED", true)
}
func (detectionHandler) Interval() time.Duration { return 15 * time.Second }
func (detectionHandler) NewPayload() any { return nil }
@@ -41,7 +28,10 @@ func (detectionHandler) Run(ctx context.Context, task *model.SystemTask, runner
_ = model.DeliverDetectionLogs()
// Keep accounting facts, but expire bulky generated artwork after the public window.
_ = model.DB.Model(&model.DetectionRun{}).Where("settled = ? AND created_at < ? AND html <> ?", true, time.Now().Add(-6*time.Hour).Unix(), "").Update("html", "").Error
scheduleErr := schedulePelicanDetections(ctx)
scheduleErr := scheduleBenchmarkDetections(ctx)
if candyErr := scheduleAutomaticDetections(ctx, "candy"); scheduleErr == nil {
scheduleErr = candyErr
}
var err error
{
var runs []model.DetectionRun
@@ -76,8 +66,12 @@ func (detectionHandler) Run(ctx context.Context, task *model.SystemTask, runner
finishSystemTaskHandler(task, runner, status, nil, err)
}
func schedulePelicanDetections(ctx context.Context) error {
channels, schedules, err := service.AutomaticDetectionChannelsDue(ctx, "pelican", time.Now())
func scheduleBenchmarkDetections(ctx context.Context) error {
return scheduleAutomaticDetections(ctx, "benchmark")
}
func scheduleAutomaticDetections(ctx context.Context, kind string) error {
channels, schedules, err := service.AutomaticDetectionChannelsDue(ctx, kind, time.Now())
if err != nil {
return err
}
@@ -87,7 +81,7 @@ func schedulePelicanDetections(ctx context.Context) error {
if len(groups) == 0 {
continue
}
price, reserve, priceErr := service.DetectionPricing("", groups[0], "pelican")
price, reserve, priceErr := service.DetectionPricing("", groups[0], kind)
if priceErr != nil {
return priceErr
}
@@ -98,7 +92,7 @@ func schedulePelicanDetections(ctx context.Context) error {
if err != nil {
return err
}
run := &model.DetectionRun{ID: id, RequestKey: fmt.Sprintf("adaptive:%d:%s", channel.Id, schedules[channel.Id].LastResultID), ChannelID: channel.Id, GroupName: groups[0], Kind: "pelican", Status: "pending", CreatedAt: now, Price: string(encoded), Reserved: reserve}
run := &model.DetectionRun{ID: id, RequestKey: fmt.Sprintf("%s:%d:%s", kind, channel.Id, schedules[channel.Id].LastResultID), ChannelID: channel.Id, GroupName: groups[0], Kind: kind, Status: "pending", CreatedAt: now, Price: string(encoded), Reserved: reserve}
if err = model.CreateDetection(run); err != nil {
return err
}
@@ -116,8 +110,6 @@ func processDetection(ctx context.Context, run model.DetectionRun) {
return
}
}
taskCtx, taskCancel := context.WithDeadline(ctx, time.Unix(run.Deadline, 0))
defer taskCancel()
if time.Now().Unix() >= run.Deadline {
var calls []model.DetectionCall
if model.DB.Where("run_id = ?", run.ID).Find(&calls).Error != nil {
@@ -137,11 +129,6 @@ func processDetection(ctx context.Context, run model.DetectionRun) {
call.Quota = 0
_ = model.FinishDetectionCall(&call)
}
if run.RemoteID != "" {
cancelCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_, _ = manxueRunRequest(cancelCtx, &run, http.MethodDelete, "/api/v1/tests/"+run.RemoteID, nil, "")
cancel()
}
finishDetection(run, "failed", "detection_timeout")
return
}
@@ -149,107 +136,16 @@ func processDetection(ctx context.Context, run model.DetectionRun) {
settleDetectionAndSchedule(run)
return
}
if run.Kind == "fingerprint" {
processPaidFingerprint(ctx, run)
if run.Kind != "fingerprint" && run.Kind != "benchmark" && run.Kind != "candy" {
finishDetection(run, "failed", "detection_retired")
return
}
if run.RemoteID == localPelicanID {
processLocalPelican(taskCtx, run)
return
}
if run.RemoteID == "" {
var existing int64
if model.DB.Model(&model.DetectionCall{}).Where("run_id = ?", run.ID).Count(&existing).Error != nil {
return
}
// A domain-separated PRF allows safe retry after a submit/commit crash;
// only its digest is stored, and the random run ID prevents enumeration.
secret := pelicanCredential(run.ID)
call := model.DetectionCall{ID: run.ID, RunID: run.ID, SecretHash: model.DetectionSecretHash(secret), ExpiresAt: run.Deadline + 300, State: "ready"}
if existing == 0 && model.DB.Create(&call).Error != nil {
return
}
payload := map[string]any{"benchmark": "pelican", "base_url": "https://ai.shrinken.pw/api/detection-proxy/v1", "api_key": secret, "model": fingerprint.Model, "protocol": "responses", "reasoning_effort": "high"}
var result gjson.Result
var err error
for taskCtx.Err() == nil {
result, err = manxueRunRequest(taskCtx, &run, http.MethodPost, "/api/v1/tests", payload, run.ID)
if errors.Is(err, errDetectionServiceUnavailable) && taskCtx.Err() == nil {
processLocalPelican(taskCtx, run)
return
}
if err == nil {
break
}
select {
case <-taskCtx.Done():
case <-time.After(5 * time.Second):
}
}
if err != nil || taskCtx.Err() != nil {
if ctx.Err() == nil {
processDetection(ctx, run)
}
return
}
remoteID := result.Get("id").String()
if remoteID == "" || len(remoteID) > 128 || strings.ContainsAny(remoteID, "/?#\\") {
finishDetection(run, "failed", "invalid_detection_response")
return
}
if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(map[string]any{"remote_id": remoteID, "status": "running"}).Error != nil {
return
}
run.RemoteID = remoteID
}
// Each worker retains its slot until completion, limiting remote generation
// concurrency as well as local submission concurrency.
for time.Now().Unix() < run.Deadline {
result, err := manxueRunRequest(taskCtx, &run, http.MethodGet, "/api/v1/tests/"+run.RemoteID, nil, "")
if errors.Is(err, errDetectionServiceUnavailable) && taskCtx.Err() == nil {
processLocalPelican(taskCtx, run)
return
}
if err != nil {
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
continue
}
switch result.Get("status").String() {
case "succeeded":
quality := result.Get("assessment.quality").String()
if quality != "normal" && quality != "degraded" {
quality = "unknown"
}
html := result.Get("result.html").String()
if len(html) > 2<<20 {
html = ""
}
metadata, _ := common.Marshal(map[string]string{"source_url": manxueArtworkURL(result)})
if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(map[string]any{"quality": quality, "html": html, "result": string(metadata)}).Error != nil {
return
}
finishDetection(run, "succeeded", "")
return
case "failed", "cancelled":
finishDetection(run, "failed", "detection_service_failed")
return
}
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
processDetection(ctx, run)
processPaidFingerprint(ctx, run)
}
func finishDetection(run model.DetectionRun, status, reason string) {
updates := map[string]any{"status": status, "error": reason}
if status == "failed" && run.Kind == "pelican" {
if status == "failed" && run.Kind == "benchmark" {
updates["result"] = ""
}
if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(updates).Error != nil {
@@ -259,7 +155,7 @@ func finishDetection(run model.DetectionRun, status, reason string) {
}
func settleDetectionAndSchedule(run model.DetectionRun) {
if model.SettleDetection(run.ID) != nil || run.UserID != 0 || run.Kind != "pelican" {
if model.SettleDetection(run.ID) != nil || run.UserID != 0 || (run.Kind != "benchmark" && run.Kind != "candy") {
return
}
// Reconciliation also repairs the clock if a process exits after settlement.
@@ -270,64 +166,7 @@ func settleDetectionAndSchedule(run model.DetectionRun) {
if run.Status == "succeeded" {
outcome = run.Quality
}
_ = model.ApplyDetectionScheduleResult(context.Background(), run.ChannelID, "pelican", run.ID, run.FinishedAt, outcome)
}
func manxueRequest(ctx context.Context, method, path string, payload any, key string) (gjson.Result, error) {
client := &http.Client{Timeout: 35 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
return manxueRequestWithClient(ctx, client, method, path, payload, key)
}
func manxueRequestWithClient(ctx context.Context, client *http.Client, method, path string, payload any, key string) (gjson.Result, error) {
var encoded []byte
if payload != nil {
var err error
encoded, err = common.Marshal(payload)
if err != nil {
return gjson.Result{}, err
}
}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, "https://manxue.ai"+path, bytes.NewReader(encoded))
if err != nil {
return gjson.Result{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0")
if strings.HasPrefix(path, "/api/visitor/") {
req.Header.Set("Origin", "https://manxue.ai")
}
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
resp, err := client.Do(req)
if err != nil {
return gjson.Result{}, errDetectionServiceUnavailable
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, (3<<20)+1))
resp.Body.Close()
if readErr != nil || resp.StatusCode >= 500 {
return gjson.Result{}, errDetectionServiceUnavailable
}
if resp.StatusCode == 429 || resp.StatusCode == 503 {
seconds, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
if at, err := http.ParseTime(resp.Header.Get("Retry-After")); err == nil {
seconds = int(time.Until(at).Seconds()) + 1
}
seconds = max(seconds, 2<<attempt)
select {
case <-ctx.Done():
return gjson.Result{}, ctx.Err()
case <-time.After(time.Duration(seconds) * time.Second):
}
continue
}
if readErr != nil || len(body) > 3<<20 || resp.StatusCode < 200 || resp.StatusCode >= 300 || !gjson.ValidBytes(body) {
return gjson.Result{}, errors.New("invalid detection service response")
}
return gjson.ParseBytes(body), nil
}
return gjson.Result{}, errDetectionServiceUnavailable
_ = model.ApplyDetectionScheduleResult(context.Background(), run.ChannelID, run.Kind, run.ID, run.FinishedAt, outcome)
}
func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
@@ -336,8 +175,20 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
finishDetection(run, "failed", "channel_unavailable")
return
}
output, calls := fingerprint.OutputTokens, fingerprint.ProbeCount
if run.Kind == "benchmark" {
output, calls = benchmark.OutputTokens, benchmark.ProbeCount
}
if run.Kind == "candy" {
output, calls = service.CandyOutputTokens, 1
}
var price service.DetectionPrice
if common.UnmarshalJsonStr(run.Price, &price) != nil || gjson.GetBytes(price.Request.Body, "max_output_tokens").Int() != int64(output) {
finishDetection(run, "failed", "benchmark_changed")
return
}
samples := []fingerprint.Sample{}
for index := 0; index < 3; index++ {
for index := 0; index < calls; index++ {
id := fmt.Sprintf("%s_%d", run.ID, index)
var previous []model.DetectionCall
if model.DB.Where("id = ?", id).Find(&previous).Error != nil {
@@ -359,7 +210,7 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
}
probeCtx, cancel := context.WithDeadline(ctx, time.Unix(run.Deadline-10, 0))
detectionCalls.Store(call.ID, cancel)
answer, count, probeErr := runAstraFingerprintProbe(probeCtx, channel, true)
answer, count, probeErr := runModelDetectionProbe(probeCtx, channel, index, run.Kind)
interrupted := probeCtx.Err() != nil
cancel()
available := probeErr == nil || answer.HasUsage
@@ -374,7 +225,7 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
usage, _ := common.Marshal(answer)
call.Usage = string(usage)
quota, quotaErr := service.DetectionUsageQuota(run.Price, answer.InputTokens, answer.OutputTokens, answer.CachedTokens, answer.CacheWriteTokens)
if quotaErr != nil || quota > run.Reserved/3 {
if quotaErr != nil || quota > run.Reserved/calls {
call.Error = "settlement_unavailable"
} else {
call.Quota = quota
@@ -382,7 +233,7 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
} else {
call.Error = "usage_unavailable"
}
if probeErr == nil && fingerprint.ValidArray(answer.Text, count) {
if probeErr == nil && ((run.Kind == "candy" && answer.Text != "") || (run.Kind == "benchmark" && benchmark.ValidSample(answer.Text)) || (run.Kind == "fingerprint" && fingerprint.ValidArray(answer.Text, count))) {
sample := fingerprint.Sample{Text: answer.Text, Count: count}
samples = append(samples, sample)
encoded, _ := common.Marshal(sample)
@@ -397,13 +248,34 @@ func processPaidFingerprint(ctx context.Context, run model.DetectionRun) {
return
}
}
result, err := fingerprint.Score(samples)
var result fingerprint.Result
if run.Kind == "benchmark" {
secondary := make([]benchmark.Sample, len(samples))
for i, sample := range samples {
secondary[i] = benchmark.Sample{Text: sample.Text, Count: sample.Count}
}
scored, scoreErr := benchmark.Score(secondary)
err = scoreErr
result = fingerprint.Result{Status: scored.Status, Engine: scored.Engine, ValidSamples: scored.ValidSamples, Closest: scored.Closest, Weight: scored.Weight, Gap: scored.Gap, Matches: scored.Matches}
} else if run.Kind == "candy" {
if len(samples) != 1 {
finishDetection(run, "failed", "answer_unavailable")
return
}
result = fingerprint.Result{Status: service.CandyQuality(samples[0].Text), Engine: "candy-v1", ValidSamples: 1, Closest: samples[0].Text}
} else {
result, err = fingerprint.Score(samples)
}
if err != nil {
finishDetection(run, "failed", "scoring_failed")
return
}
quality := result.Status
if run.Kind == "benchmark" {
quality = service.FingerprintScheduleOutcome(result.Status)
}
encoded, _ := common.Marshal(result)
if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(map[string]any{"quality": result.Status, "result": string(encoded)}).Error != nil {
if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(map[string]any{"quality": quality, "result": string(encoded)}).Error != nil {
return
}
finishDetection(run, "succeeded", "")
+20 -2
View File
@@ -34,6 +34,20 @@ func GetUserGroups(c *gin.Context) {
userGroup := ""
userId := c.GetInt("id")
userGroup, _ = model.GetUserGroup(userId, false)
if err := model.LoadPersonalGroupRatios(c, userId); err != nil {
common.ApiError(c, err)
return
}
metadata, err := model.GetGroupMetadata(c.Request.Context())
if err != nil {
common.ApiError(c, err)
return
}
recent, err := service.RecentDegradedGroups(c.Request.Context())
if err != nil {
common.ApiError(c, err)
return
}
userUsableGroups := service.GetUserUsableGroups(userGroup)
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
if _, ok := enabledGroups[groupName]; !ok {
@@ -42,8 +56,12 @@ func GetUserGroups(c *gin.Context) {
// UserUsableGroups contains the groups that the user can use
if desc, ok := userUsableGroups[groupName]; ok {
usableGroups[groupName] = map[string]any{
"ratio": service.GetUserGroupRatio(userGroup, groupName),
"desc": desc,
"ratio": model.EffectiveGroupRatio(c, userGroup, groupName).GroupRatio,
"base_ratio": ratio_setting.GetGroupRatio(groupName),
"has_personal_ratio": model.HasPersonalGroupRatio(c, groupName),
"tags": metadata[groupName].Tags,
"recent_degraded": recent[groupName] && !metadata[groupName].HideRecentDegraded,
"desc": desc,
}
}
}
+86
View File
@@ -0,0 +1,86 @@
package controller
import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
func groupRatioTarget(c *gin.Context) (int, bool) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid user"})
return 0, false
}
user, err := model.GetUserById(id, false)
if err != nil {
common.ApiError(c, err)
return 0, false
}
if c.GetInt("role") < common.RoleAdminUser || !canManageTargetRole(c.GetInt("role"), user.Role) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "Permission denied"})
return 0, false
}
return id, true
}
func GetUserGroupRatios(c *gin.Context) {
id, ok := groupRatioTarget(c)
if !ok {
return
}
ratios, err := model.GetPersonalGroupRatios(c.Request.Context(), id)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": ratios})
}
func UpdateUserGroupRatios(c *gin.Context) {
id, ok := groupRatioTarget(c)
if !ok {
return
}
var input struct {
Ratios map[string]float64 `json:"ratios"`
}
if err := c.ShouldBindJSON(&input); err != nil || input.Ratios == nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid ratios"})
return
}
if err := model.SavePersonalGroupRatios(c.Request.Context(), id, input.Ratios); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, id, "user.group_ratios.update", map[string]any{"ratios": input.Ratios})
c.JSON(http.StatusOK, gin.H{"success": true})
}
func GetGroupMetadata(c *gin.Context) {
groups, err := model.GetGroupMetadata(c.Request.Context())
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": groups})
}
func UpdateGroupMetadata(c *gin.Context) {
var input struct {
Groups map[string]model.GroupMetadataValue `json:"groups"`
}
if err := c.ShouldBindJSON(&input); err != nil || input.Groups == nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid group metadata"})
return
}
if err := model.SaveGroupMetadata(c.Request.Context(), input.Groups); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, 0, "group.metadata.update", map[string]any{"groups": input.Groups})
c.JSON(http.StatusOK, gin.H{"success": true})
}
+65
View File
@@ -0,0 +1,65 @@
package controller
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestPersonalRatioAdminAuthorization(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.PersonalGroupRatio{}, &model.AuditLog{}))
oldLog := model.LOG_DB
model.LOG_DB = db
old, oldRedis := model.DB, common.RedisEnabled
model.DB, common.RedisEnabled = db, false
t.Cleanup(func() {
model.LOG_DB = oldLog
model.DB, common.RedisEnabled = old, oldRedis
conn, _ := db.DB()
_ = conn.Close()
})
user := model.User{Id: 12, Username: "personal-test", Role: common.RoleCommonUser}
require.NoError(t, db.Create(&user).Error)
for _, role := range []int{common.RoleCommonUser, common.RoleAdminUser, common.RoleRootUser} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: "12"}}
c.Request = httptest.NewRequest("PUT", "/api/user/12/group-ratios", bytes.NewBufferString("{\"ratios\":{\"default\":0}}"))
c.Request.Header.Set("Content-Type", "application/json")
UpdateUserGroupRatios(c)
if role == common.RoleCommonUser {
require.Equal(t, 403, w.Code)
} else {
var body struct{ Success bool }
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body.Success, w.Body.String())
}
}
var auditCount int64
require.NoError(t, db.Model(&model.AuditLog{}).Where("action = ?", "user.group_ratios.update").Count(&auditCount).Error)
require.EqualValues(t, 2, auditCount)
value, err := model.GetPersonalGroupRatios(context.Background(), 12)
require.NoError(t, err)
require.Equal(t, 0.0, value["default"])
require.NoError(t, db.Model(&user).Update("role", common.RoleAdminUser).Error)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", common.RoleAdminUser)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(user.Id)}}
c.Request = httptest.NewRequest("GET", "/api/user/12/group-ratios", nil)
GetUserGroupRatios(c)
require.Equal(t, 403, w.Code)
}
+2 -2
View File
@@ -32,13 +32,13 @@ func TestCurrentGroupStatus(t *testing.T) {
{"future evidence is ignored", now + 1, []service.ProbeHistoryPoint{{At: now + 1, Status: 1}}, false, -1, "detection", 0},
} {
t.Run(tc.name, func(t *testing.T) {
m := Monitor{Status: 0, StatusSource: "requests", StatusCheckedAt: now - 100, ChannelDisabled: tc.disabled, ProbeHistory: tc.probes, Uptime: 0.58, Fingerprint: &service.AstraSamplingSummary{Errors: 25}, Pelican: &service.PelicanSummary{Failed: 33}}
m := Monitor{Status: 0, StatusSource: "requests", StatusCheckedAt: now - 100, ChannelDisabled: tc.disabled, ProbeHistory: tc.probes, Uptime: 0.58, Fingerprint: &service.AstraSamplingSummary{Errors: 25}, Benchmark: &service.BenchmarkSummary{Failed: 33}}
applyCurrentStatus(&m, perfmetrics.UserRequestResult{At: tc.success * 1000, Success: true}, now)
assert.Equal(t, tc.want, m.Status)
assert.Equal(t, tc.source, m.StatusSource)
assert.Equal(t, tc.at, m.StatusCheckedAt)
assert.Equal(t, 25, m.Fingerprint.Errors)
assert.Equal(t, 33, m.Pelican.Failed)
assert.Equal(t, 33, m.Benchmark.Failed)
assert.Equal(t, 0.58, m.Uptime)
})
}
-19
View File
@@ -1,19 +0,0 @@
package controller
import (
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"testing"
)
func TestManxueArtworkURL(t *testing.T) {
assert.Empty(t, manxueArtworkURL(gjson.Parse(`{"public":false,"result":{"id":"private"}}`)))
assert.Equal(t, "https://manxue.ai/?result=community-one", manxueArtworkURL(gjson.Parse(`{"public":true,"result":{"id":"community-one"}}`)))
assert.Empty(t, manxueArtworkURL(gjson.Parse(`{"share_url":"https://evil.test/?result=one"}`)))
}
func TestArtworkPreservesScriptDrawnLegs(t *testing.T) {
result := sanitizeDetectionHTML(`<svg><path id="leg"></path></svg><button id="toggle">pause</button><script>document.getElementById('leg').setAttribute('d','M0 0L10 10')</script>`)
assert.Contains(t, result, "<script>")
assert.Contains(t, result, "setAttribute")
assert.Contains(t, result, "<button")
}
-64
View File
@@ -1,64 +0,0 @@
package controller
import (
"context"
"errors"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/tidwall/gjson"
)
// Visitor sessions belong to a single detection. Keep them in its private task
// metadata until completion so polling can resume after a process restart.
type manxueVisitorSession struct {
Cookies []*http.Cookie `json:"manxue_cookies,omitempty"`
}
func manxueRunRequest(ctx context.Context, run *model.DetectionRun, method, path string, payload any, key string) (gjson.Result, error) {
var session manxueVisitorSession
_ = common.UnmarshalJsonStr(run.Result, &session)
if len(session.Cookies) == 0 && !(method == http.MethodPost && run.RemoteID == "") {
return manxueRequest(ctx, method, path, payload, key)
}
jar, _ := cookiejar.New(nil)
origin, _ := url.Parse("https://manxue.ai/api/visitor/")
client := &http.Client{Timeout: 35 * time.Second, Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
if len(session.Cookies) == 0 {
if _, err := manxueRequestWithClient(ctx, client, http.MethodGet, "/api/visitor/config", nil, ""); err != nil {
return gjson.Result{}, errDetectionServiceUnavailable
}
session.Cookies = jar.Cookies(origin)
if len(session.Cookies) == 0 {
return gjson.Result{}, errDetectionServiceUnavailable
}
encoded, err := common.Marshal(session)
if err != nil {
return gjson.Result{}, err
}
if err = model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Update("result", string(encoded)).Error; err != nil {
return gjson.Result{}, err
}
run.Result = string(encoded)
} else {
jar.SetCookies(origin, session.Cookies)
}
if method == http.MethodPost {
fields, ok := payload.(map[string]any)
if !ok {
return gjson.Result{}, errors.New("invalid pelican payload")
}
public := make(map[string]any, len(fields)+1)
for k, v := range fields {
public[k] = v
}
public["public"] = true
payload = public
}
return manxueRequestWithClient(ctx, client, method, strings.Replace(path, "/api/v1/tests", "/api/visitor/tests", 1), payload, key)
}
-80
View File
@@ -1,80 +0,0 @@
package controller
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"gorm.io/gorm"
)
type manxueTestTransport func(*http.Request) (*http.Response, error)
func (f manxueTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestManxuePublicDetectionResumesAndReturnsPublicURL(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
oldDB, oldTransport := model.DB, http.DefaultTransport
model.DB = db
t.Cleanup(func() { model.DB = oldDB; http.DefaultTransport = oldTransport; _ = conn.Close() })
require.NoError(t, db.AutoMigrate(&model.DetectionRun{}))
configCalls, createCalls := 0, 0
http.DefaultTransport = manxueTestTransport(func(r *http.Request) (*http.Response, error) {
w := httptest.NewRecorder()
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/visitor/config":
configCalls++
http.SetCookie(w, &http.Cookie{Name: "gallery_visitor", Value: "test-session", Path: "/api/visitor/", Secure: true, HttpOnly: true})
_, _ = w.WriteString(`{"prompt":"pelican"}`)
case "/api/visitor/tests":
createCalls++
cookie, err := r.Cookie("gallery_visitor")
require.NoError(t, err)
require.Equal(t, "test-session", cookie.Value)
require.Equal(t, "stable-key", r.Header.Get("Idempotency-Key"))
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
require.Equal(t, true, body["public"])
_, _ = w.WriteString(`{"id":"remote-one","status":"running"}`)
case "/api/visitor/tests/remote-one":
_, err := r.Cookie("gallery_visitor")
require.NoError(t, err)
_, _ = w.WriteString(`{"status":"succeeded","public_id":"community-one","result":{"html":"<html>pelican</html>"}}`)
case "/api/v1/tests/legacy":
require.Empty(t, r.Header.Get("Cookie"))
_, _ = w.WriteString(`{"status":"succeeded"}`)
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
return w.Result(), nil
})
run := model.DetectionRun{ID: "public-test", RequestKey: "public-test", Kind: "pelican"}
require.NoError(t, db.Create(&run).Error)
input := map[string]any{"benchmark": "pelican"}
_, err = manxueRunRequest(context.Background(), &run, http.MethodPost, "/api/v1/tests", input, "stable-key")
require.NoError(t, err)
require.NotContains(t, input, "public")
var resumed model.DetectionRun
require.NoError(t, db.First(&resumed, "id = ?", run.ID).Error)
resumed.RemoteID = "remote-one"
result, err := manxueRunRequest(context.Background(), &resumed, http.MethodGet, "/api/v1/tests/remote-one", nil, "")
require.NoError(t, err)
require.Equal(t, "https://manxue.ai/?result=community-one", manxueArtworkURL(result))
require.Equal(t, 1, configCalls)
require.Equal(t, 1, createCalls)
legacy := model.DetectionRun{RemoteID: "legacy"}
_, err = manxueRunRequest(context.Background(), &legacy, http.MethodGet, "/api/v1/tests/legacy", nil, "")
require.NoError(t, err)
require.Empty(t, manxueArtworkURL(gjson.Parse(`{"id":"private","public":false}`)))
}
+6 -1
View File
@@ -260,7 +260,12 @@ func ListModels(c *gin.Context, modelType int) {
tokenModelLimit = map[string]bool{}
}
}
models := service.GetGroupsEnabledModels(ownerGroups)
var models []string
if policy := service.RequestRoutingPolicy(c); policy != nil && policy.Mode == "smart" {
models = service.AutomaticPoolModels(c)
} else {
models = service.GetGroupsEnabledModels(ownerGroups)
}
for _, modelName := range models {
if modelLimitEnable {
matchingName := ratio_setting.RoutingMatchModelName(modelName)
-96
View File
@@ -1,96 +0,0 @@
package controller
import (
"bytes"
"context"
"errors"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"net/http"
"net/http/httptest"
"strings"
)
var errDetectionServiceUnavailable = errors.New("detection service unreachable")
const localPelicanID = "local-pelican"
const pelicanPrompt = "Create a complete self-contained HTML page showing a pelican riding a bicycle. Draw the pelican and bicycle with inline SVG and animate pedaling and rotating wheels using CSS. Make the scene visually polished. Do not load external resources or use JavaScript. Return only the complete HTML document."
func detectionHTML(raw []byte, contentType string) string {
result := gjson.ParseBytes(raw)
if strings.Contains(contentType, "text/event-stream") {
result = gjson.Result{}
for line := range strings.SplitSeq(string(raw), "\n") {
if strings.HasPrefix(line, "data:") {
event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
if event.Get("type").String() == "response.completed" {
result = event.Get("response")
}
}
}
}
if result.Get("status").String() != "completed" {
return ""
}
var output strings.Builder
for _, item := range result.Get("output").Array() {
if item.Get("type").String() != "message" {
continue
}
for _, part := range item.Get("content").Array() {
if part.Get("type").String() == "output_text" {
output.WriteString(part.Get("text").String())
}
}
}
html := strings.TrimSpace(output.String())
lower := strings.ToLower(html)
start := strings.Index(lower, "<!doctype html")
if start < 0 {
start = strings.Index(lower, "<html")
}
end := strings.LastIndex(lower, "</html>")
if start < 0 || end < start || len(html) > 2<<20 {
return ""
}
return html[start : end+7]
}
func processLocalPelican(ctx context.Context, run model.DetectionRun) {
if ctx.Err() != nil {
return
}
if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(map[string]any{"remote_id": localPelicanID, "quality": "unknown", "result": ""}).Error != nil {
return
}
var call model.DetectionCall
if model.DB.First(&call, "id = ?", run.ID).Error != nil {
return
}
if call.State == "running" {
return
}
if call.State == "ready" {
payload, _ := common.Marshal(map[string]any{"model": "gpt-6-astra", "input": pelicanPrompt})
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/detection-proxy/v1/responses", bytes.NewReader(payload))
c.Request.Header.Set("Authorization", "Bearer "+pelicanCredential(run.ID))
DetectionProxy(c)
if model.DB.First(&call, "id = ?", run.ID).Error != nil {
return
}
}
if call.State != "finished" {
return
}
if model.DB.First(&run, "id = ?", run.ID).Error != nil {
return
}
if run.HTML != "" {
finishDetection(run, "succeeded", "")
} else {
finishDetection(run, "failed", "local_generation_failed")
}
}
-14
View File
@@ -1,14 +0,0 @@
package controller
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestDetectionHTMLRequiresCompletedDocument(t *testing.T) {
raw := []byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"<html><body>pelican</body></html>"}]}]}`)
assert.Equal(t, "<html><body>pelican</body></html>", detectionHTML(raw, "application/json"))
assert.Empty(t, detectionHTML([]byte(`{"status":"incomplete","output":[]}`), "application/json"))
sse := []byte("data: {\"type\":\"response.completed\",\"response\":" + string(raw) + "}\n\n")
assert.Equal(t, detectionHTML(raw, "application/json"), detectionHTML(sse, "text/event-stream"))
}
@@ -1,50 +0,0 @@
package controller
import (
"context"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/service"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestLocalPelicanGeneratesOnceWithUnknownVerdict(t *testing.T) {
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.DetectionRun{}, &model.DetectionCall{}, &model.DetectionSchedule{}))
calls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"completed","usage":{"input_tokens":10,"output_tokens":5},"output":[{"type":"message","content":[{"type":"output_text","text":"<html><body>pelican</body></html>"}]}]}`))
}))
defer upstream.Close()
url := upstream.URL
channel := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: "gpt-6-astra"}
require.NoError(t, db.Create(&channel).Error)
price, _ := common.Marshal(service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: "p+c", ExprHash: billingexpr.ExprHashString("p+c"), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1}})
run := model.DetectionRun{ID: "local-test", RequestKey: "local-test", Kind: "pelican", ChannelID: channel.Id, Status: "running", Deadline: time.Now().Unix() + 60, Reserved: 100, Price: string(price)}
require.NoError(t, db.Create(&run).Error)
require.NoError(t, db.Create(&model.DetectionCall{ID: run.ID, RunID: run.ID, SecretHash: model.DetectionSecretHash(pelicanCredential(run.ID)), State: "ready", ExpiresAt: run.Deadline}).Error)
processLocalPelican(context.Background(), run)
require.NoError(t, db.First(&run, "id = ?", run.ID).Error)
require.Equal(t, "succeeded", run.Status)
require.Equal(t, "unknown", run.Quality)
require.Contains(t, run.HTML, "pelican")
require.True(t, run.Settled)
processLocalPelican(context.Background(), run)
require.Equal(t, 1, calls)
}
+5 -4
View File
@@ -52,11 +52,12 @@ func GetPricing(c *gin.Context) {
user, err := model.GetUserCache(userId.(int))
if err == nil {
group = user.Group
if err := model.LoadPersonalGroupRatios(c, user.Id); err != nil {
common.ApiError(c, err)
return
}
for g := range groupRatio {
ratio, ok := ratio_setting.GetGroupGroupRatio(group, g)
if ok {
groupRatio[g] = ratio
}
groupRatio[g] = model.EffectiveGroupRatio(c, group, g).GroupRatio
}
}
}
+17 -2
View File
@@ -194,9 +194,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
relayInfo.RetryIndex = 0
relayInfo.LastError = nil
maxRetries := common.RetryTimes
poolBudget := service.PoolRetryBudget(c, relayInfo.OriginModelName)
poolFailures := 0
maxRetries = max(maxRetries, poolBudget)
ordinaryRetries := 0
if relayInfo.DurableAttemptBilling {
maxRetries = 2
maxRetries = max(maxRetries, 2)
timeout := constant.StreamingTimeout
if timeout <= 0 {
timeout = 300
@@ -210,7 +213,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = types.NewErrorWithStatusCode(errors.New("response model confirmation budget exhausted"), relaycommon.ResponseGuardErrorCode, http.StatusGatewayTimeout, types.ErrOptionWithSkipRetry())
break
}
if relayInfo.ResponseGuard != nil && relayInfo.UpstreamRequests >= relaycommon.ResponseGuardMaxAttempts {
if relayInfo.ResponseGuard != nil && relayInfo.UpstreamRequests-poolFailures >= relaycommon.ResponseGuardMaxAttempts {
newAPIError = types.NewErrorWithStatusCode(errors.New("response model attempt budget exhausted"), relaycommon.ResponseGuardErrorCode, http.StatusBadGateway, types.ErrOptionWithSkipRetry())
break
}
@@ -259,6 +262,9 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
if newAPIError == nil {
service.MarkRequestPolicySuccess(c, relayInfo.StreamStatus)
if service.RequestPolicy(c).Successful {
service.RecordPoolSuccess(c, relayInfo.OriginModelName, currentRequestGroup(c, channel))
}
relayInfo.LastError = nil
service.RecordGroupOutcome(currentRequestGroup(c, channel), true)
return
@@ -276,7 +282,16 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
if relayInfo.DurableAttemptBilling {
remaining = common.RetryTimes - ordinaryRetries
}
poolFailure := service.IsPoolFailoverError(c, newAPIError)
if poolFailure {
remaining = maxRetries - retryParam.GetRetry()
poolFailures++
service.RecordPoolRetryableFailure(c, relayInfo.OriginModelName, currentRequestGroup(c, channel))
}
decision := service.DecideRelayRetry(c, newAPIError, remaining)
if poolFailure && service.PoolRetryExhausted(c, relayInfo.OriginModelName, currentRequestGroup(c, channel)) {
decision = service.PolicyDecision{Action: "stop", Reason: "pool_no_backup", Source: "routing"}
}
if canSwitchRejectedAstra(c, relayInfo, newAPIError) {
decision = service.PolicyDecision{Action: "retry", Reason: "model_mismatch_switch", Source: "model_guard"}
service.ClearPoolAffinity(c)
+81 -1
View File
@@ -25,8 +25,10 @@ import (
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
@@ -38,6 +40,84 @@ import (
var responsesWSTestUserSequence atomic.Int64
func TestResponsesHTTPPoolRetriesTwiceThenUsesBackup(t *testing.T) {
fixture := newResponsesWSBillingTest(t, `tier("request", fixed(0.002))`, func(*websocket.Conn, *http.Request) {})
oldRetries := common.RetryTimes
common.RetryTimes = 0
t.Cleanup(func() { common.RetryTimes = oldRetries })
oldGroups := setting.UserUsableGroups2JSONString()
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
t.Cleanup(func() { require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(oldGroups)) })
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
if os.Getenv("TEST_PERSONAL_RATIO") == "1" {
require.NoError(t, model.SavePersonalGroupRatios(context.Background(), fixture.user.Id, map[string]float64{"default": 0.8, "vip": 0.2}))
}
service.ResetGroupAvailability()
t.Cleanup(service.ResetGroupAvailability)
var primaryCalls, backupCalls atomic.Int64
fixture.httpUpstream = func(w http.ResponseWriter, r *http.Request) {
primaryCalls.Add(1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
_, _ = fmt.Fprint(w, `{"error":{"type":"server_error","code":"server_error","message":"test failure"}}`)
}
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupCalls.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"id":"completed","status":"completed","usage":{"input_tokens":1000,"output_tokens":1,"total_tokens":1001}}`)
}))
t.Cleanup(backup.Close)
require.NoError(t, model.DB.Model(fixture.channel).Updates(map[string]any{"key": "single-primary-key", "channel_info": `{}`, "auto_ban": 0}).Error)
backupChannel := &model.Channel{Name: "backup-pool-test", Key: "test-only", Status: common.ChannelStatusEnabled, Type: constant.ChannelTypeOpenAI, Group: "vip", Models: "ws-billing", BaseURL: &backup.URL, AutoBan: common.GetPointer(0)}
require.NoError(t, model.DB.Create(backupChannel).Error)
require.NoError(t, model.DB.Create(&model.Ability{ChannelId: backupChannel.Id, Model: "ws-billing", Group: "vip", Enabled: true}).Error)
t.Cleanup(func() {
require.NoError(t, model.DB.Where("channel_id = ?", backupChannel.Id).Delete(&model.Ability{}).Error)
require.NoError(t, model.DB.Delete(backupChannel).Error)
})
require.NoError(t, model.DB.First(fixture.token, fixture.token.Id).Error)
body, err := common.Marshal(model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}, {Group: "vip", Priority: 1}}})
require.NoError(t, err)
fixture.token.RoutingPolicy = string(body)
fixture.token.Group = "default"
fixture.token.CrossGroupRetry = true
require.NoError(t, model.DB.Model(fixture.token).Updates(map[string]any{"routing_policy": string(body), "group": "default", "cross_group_retry": true}).Error)
require.NoError(t, fixture.token.SelectUpdate())
for range 2 {
req, err := http.NewRequest(http.MethodPost, fixture.gatewayURL+"/v1/responses", strings.NewReader(`{"model":"ws-billing","input":"hello"}`))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer sk-"+fixture.token.Key)
req.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(req)
require.NoError(t, err)
data, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.Equal(t, 200, response.StatusCode, string(data))
select {
case <-fixture.httpDone:
case <-time.After(3 * time.Second):
t.Fatal("HTTP request did not finish")
}
}
require.Equal(t, int64(2), primaryCalls.Load())
require.Equal(t, int64(2), backupCalls.Load())
var logs []model.Log
require.NoError(t, model.LOG_DB.Where("token_id = ? AND type = ?", fixture.token.Id, model.LogTypeConsume).Find(&logs).Error)
require.Len(t, logs, 2)
for _, log := range logs {
require.Equal(t, "vip", log.Group)
expected := 1000
if os.Getenv("TEST_PERSONAL_RATIO") == "1" {
expected = 200
}
require.Equal(t, expected, log.Quota)
}
if os.Getenv("TEST_PERSONAL_RATIO") == "1" {
assertResponsesWSAccounting(t, fixture, []int{200, 200})
}
}
func setupResponsesWSRequestTest(t *testing.T) (*model.User, *model.Token) {
t.Helper()
previousDB := model.DB
@@ -88,7 +168,7 @@ func setupResponsesWSRequestTest(t *testing.T) (*model.User, *model.Token) {
setting.ModelRequestRateLimitMutex.Unlock()
require.NoError(t, sqlDB.Close())
})
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Token{}))
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Token{}, &model.PersonalGroupRatio{}))
// The shared in-memory limiter outlives each database fixture. Give every
// user a separate quota bucket, including when the tests run with -count.
user := &model.User{Id: 5062000 + int(responsesWSTestUserSequence.Add(1)), Username: "responses-ws-user", Status: common.UserStatusEnabled, Group: "default", Quota: 1000, AuthVersion: 1}
+24
View File
@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@@ -60,6 +61,7 @@ func UpdateStatusPageGroup(c *gin.Context) {
common.ApiError(c, err)
return
}
service.InvalidateGroupQualityCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -70,12 +72,34 @@ func GetSiteGroupStatus(c *gin.Context) {
common.ApiError(c, err)
return
}
costs, costErr := service.GroupModelCosts(c.Request.Context())
if costErr != nil {
common.SysError("group model costs: " + costErr.Error())
}
metadata, err := model.GetGroupMetadata(c.Request.Context())
if err != nil {
common.ApiError(c, err)
return
}
recent, err := service.RecentDegradedGroups(c.Request.Context())
if err != nil {
common.ApiError(c, err)
return
}
for i := range results {
monitors := make([]Monitor, 0, len(results[i].Monitors))
for _, monitor := range results[i].Monitors {
monitor.Tags = metadata[monitor.Group].Tags
monitor.RecentDegraded = recent[monitor.Group] && !metadata[monitor.Group].HideRecentDegraded
monitor.Name = monitor.Group
monitor.Alias = ""
monitor.Hidden = false
monitor.ModelCosts = map[string]model.GroupModelCost{}
for _, name := range monitor.Models {
if value, ok := costs[monitor.Group][name]; ok {
monitor.ModelCosts[name] = value
}
}
monitors = append(monitors, monitor)
}
results[i].Monitors = monitors
+5 -6
View File
@@ -2,6 +2,7 @@ package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
@@ -29,7 +30,7 @@ func setTokenRoutingPolicy(c *gin.Context, token *model.Token, policy *model.Tok
token.RoutingPolicy = string(body)
token.CrossGroupRetry = true
token.AutoGroups = ""
if policy.Mode == "inherit" {
if policy.Mode == "inherit" || policy.Mode == "smart" {
token.Group = "auto"
} else if policy.Mode == "user" {
token.Group = ""
@@ -57,10 +58,8 @@ func PreviewTokenRouting(c *gin.Context) {
common.ApiError(c, err)
return
}
inherited := service.GetUserAutoGroup(group)
if request.Policy.Mode == "user" {
inherited = []string{group}
}
entries := request.Policy.Resolve(request.Model, inherited, func(candidate string) bool { return service.IsUserSelectableGroup(group, candidate) })
common.SetContextKey(c, constant.ContextKeyUserGroup, group)
c.Set(model.TokenRoutingContextKey, &request.Policy)
entries := service.ResolveRequestPool(c, request.Model)
common.ApiSuccess(c, entries)
}
+29 -22
View File
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/model"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
@@ -18,26 +19,31 @@ import (
const requestTimeout = 30 * time.Second
type Monitor struct {
ProbeHistory []service.ProbeHistoryPoint `json:"probe_history"`
ID string `json:"id"`
Models []string `json:"models"`
Alias string `json:"alias,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Pelican *service.PelicanSummary `json:"pelican,omitempty"`
StatusSource string `json:"status_source"`
StatusCheckedAt int64 `json:"status_checked_at,omitempty"`
Fingerprint *service.AstraSamplingSummary `json:"fingerprint,omitempty"`
Metrics *perfmetrics.GroupResult `json:"metrics,omitempty"`
Name string `json:"name"`
Uptime float64 `json:"uptime"`
Status int `json:"status"`
Group string `json:"group,omitempty"`
Ping int `json:"ping,omitempty"`
LastChecked string `json:"last_checked,omitempty"`
History []MonitorSample `json:"history,omitempty"`
CacheStats *model.GroupCacheStats `json:"cache_stats,omitempty"`
ProbeStatus int `json:"probe_status"`
ChannelDisabled bool `json:"channel_disabled"`
Tags []string `json:"tags,omitempty"`
RecentDegraded bool `json:"recent_degraded,omitempty"`
ModelCosts map[string]model.GroupModelCost `json:"model_costs,omitempty"`
GroupRatio float64 `json:"group_ratio"`
ProbeHistory []service.ProbeHistoryPoint `json:"probe_history"`
ID string `json:"id"`
Models []string `json:"models"`
Alias string `json:"alias,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Candy *service.BenchmarkSummary `json:"candy,omitempty"`
Benchmark *service.BenchmarkSummary `json:"benchmark,omitempty"`
StatusSource string `json:"status_source"`
StatusCheckedAt int64 `json:"status_checked_at,omitempty"`
Fingerprint *service.AstraSamplingSummary `json:"fingerprint,omitempty"`
Metrics *perfmetrics.GroupResult `json:"metrics,omitempty"`
Name string `json:"name"`
Uptime float64 `json:"uptime"`
Status int `json:"status"`
Group string `json:"group,omitempty"`
Ping int `json:"ping,omitempty"`
LastChecked string `json:"last_checked,omitempty"`
History []MonitorSample `json:"history,omitempty"`
CacheStats *model.GroupCacheStats `json:"cache_stats,omitempty"`
ProbeStatus int `json:"probe_status"`
ChannelDisabled bool `json:"channel_disabled"`
}
type MonitorSample struct {
@@ -129,7 +135,7 @@ func currentGroupMonitors(ctx context.Context, admin bool) ([]UptimeGroupResult,
userResults := perfmetrics.LatestUserResults(ctx, order)
monitors := make([]Monitor, 0, len(order))
for _, group := range order {
monitor := Monitor{ID: model.DetectionSecretHash(group)[:16], Name: group, Status: -1, Models: models[group]}
monitor := Monitor{ID: model.DetectionSecretHash(group)[:16], Name: group, Status: -1, Models: models[group], GroupRatio: ratio_setting.GetGroupRatio(group)}
if monitor.Models == nil {
monitor.Models = []string{}
}
@@ -157,7 +163,8 @@ func currentGroupMonitors(ctx context.Context, admin bool) ([]UptimeGroupResult,
if !admin && monitor.Metrics != nil {
monitor.Metrics.Group = monitor.Name
}
monitor.Pelican = detections[group].Pelican
monitor.Benchmark = detections[group].Benchmark
monitor.Candy = detections[group].Candy
monitor.ProbeHistory = detections[group].ProbeHistory
applyCurrentStatus(&monitor, userResults[group], now.Unix())
monitors = append(monitors, monitor)
+33 -19
View File
@@ -9,6 +9,7 @@ import (
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
@@ -51,10 +52,11 @@ func TestStatusPageDatabaseMatrix(t *testing.T) {
oldType, oldLogType := common.MainDatabaseType(), common.LogDatabaseType()
oldRedis, oldSecret, oldSecure := common.RedisEnabled, common.SessionSecret, common.SessionCookieSecure
oldGroups := setting.UserUsableGroups2JSONString()
oldRatios := ratio_setting.GroupRatio2JSONString()
model.DB, model.LOG_DB = db, db
common.SetDatabaseTypes(common.DatabaseType(dialect), common.DatabaseType(dialect))
common.RedisEnabled, common.SessionSecret, common.SessionCookieSecure = false, "status-test-only", true
rows := []any{&model.Option{}, &model.Channel{}, &model.Ability{}, &model.SystemTask{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.DetectionSchedule{}, &model.Log{}, &model.PerfMetric{}, &model.User{}, &model.UserSession{}}
rows := []any{&model.GroupMetadata{}, &model.Option{}, &model.Channel{}, &model.Ability{}, &model.SystemTask{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.DetectionSchedule{}, &model.Log{}, &model.PerfMetric{}, &model.User{}, &model.UserSession{}}
require.NoError(t, db.AutoMigrate(rows...))
require.NoError(t, db.Table("audit_logs").AutoMigrate(&model.AuditLog{}))
t.Cleanup(func() {
@@ -66,6 +68,7 @@ func TestStatusPageDatabaseMatrix(t *testing.T) {
common.SetDatabaseTypes(oldType, oldLogType)
common.RedisEnabled, common.SessionSecret, common.SessionCookieSecure = oldRedis, oldSecret, oldSecure
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(oldGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(oldRatios))
groupCacheSnapshot.updated = time.Time{}
require.NoError(t, conn.Close())
})
@@ -77,15 +80,17 @@ func TestStatusPageDatabaseMatrix(t *testing.T) {
require.NoError(t, db.Raw(q).Scan(&version).Error)
t.Log("database version:", version)
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"internal":"","shared":"","hidden":""}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"internal":0.25,"shared":0,"hidden":1}`))
now := time.Now().Unix()
channels := []model.Channel{{Id: 1, Name: "private-channel", Key: "private-key", Group: "internal,shared", Models: fingerprint.Model, Status: 1}, {Id: 2, Group: "hidden", Models: fingerprint.Model, Status: 1}, {Id: 3, Group: "internal", Models: "disabled-model", Status: 2}}
require.NoError(t, db.Create(&channels).Error)
abilities := []model.Ability{{Group: "internal", Model: fingerprint.Model, ChannelId: 1, Enabled: true}, {Group: "shared", Model: fingerprint.Model, ChannelId: 1, Enabled: true}, {Group: "hidden", Model: fingerprint.Model, ChannelId: 2, Enabled: true}, {Group: "internal", Model: "disabled-model", ChannelId: 3, Enabled: true}, {Group: "internal", Model: "disabled-ability", ChannelId: 1}}
require.NoError(t, db.Create(&abilities).Error)
state, err := common.Marshal(service.AstraSamplingState{Rounds: []service.AstraSamplingRound{{ID: "old-round", ChannelID: 1, StartedAt: now - 20, FinishedAt: now - 10, Result: fingerprint.Result{Status: "match", Closest: fingerprint.Model, ValidSamples: 3}}}})
require.NoError(t, db.Create(&model.Log{Type: model.LogTypeConsume, TokenId: 1, ChannelId: 1, Group: "internal", ModelName: fingerprint.Model, Quota: 50, PromptTokens: 1000, CreatedAt: now - 10, Other: `{}`}).Error)
state, err := common.Marshal(service.AstraSamplingState{Rounds: []service.AstraSamplingRound{{ID: "old-round", ChannelID: 1, StartedAt: now - 20, FinishedAt: now - 10, Result: fingerprint.Result{Engine: fingerprint.Version, Status: "match", Closest: fingerprint.Model, ValidSamples: 3}}}})
require.NoError(t, err)
require.NoError(t, db.Create(&model.SystemTask{TaskID: "old-task", Type: service.AstraSamplingTaskType, Status: model.SystemTaskStatusSucceeded, CreatedAt: now - 20, State: string(state)}).Error)
runs := []model.DetectionRun{{ID: "old-work", RequestKey: "old-work", ChannelID: 1, GroupName: "internal", Kind: "pelican", Status: "succeeded", Quality: "normal", CreatedAt: now - 20, Settled: true, HTML: "<h1>art</h1>", Charged: 17}, {ID: "in-flight", RequestKey: "in-flight", ChannelID: 1, Kind: "pelican", Status: "running", CreatedAt: now - 10}, {ID: "hidden-art", RequestKey: "hidden-art", ChannelID: 2, Kind: "pelican", Status: "succeeded", CreatedAt: now - 20, HTML: "hidden"}}
runs := []model.DetectionRun{{ID: "old-work", RequestKey: "old-work", ChannelID: 1, GroupName: "internal", Kind: "benchmark", Status: "succeeded", Quality: "normal", CreatedAt: now - 20, Settled: true, HTML: "<h1>art</h1>", Charged: 17}, {ID: "in-flight", RequestKey: "in-flight", ChannelID: 1, Kind: "benchmark", Status: "running", CreatedAt: now - 10}, {ID: "hidden-art", RequestKey: "hidden-art", ChannelID: 2, Kind: "benchmark", Status: "succeeded", CreatedAt: now - 20, HTML: "hidden"}}
require.NoError(t, db.Create(&runs).Error)
tokens := map[int]string{}
for _, role := range []int{1, 10} {
@@ -131,23 +136,32 @@ func TestStatusPageDatabaseMatrix(t *testing.T) {
groupCacheSnapshot.updated = time.Time{}
res := request("GET", "/api/uptime/status", "", "", "")
require.Equal(t, 200, res.Code, res.Body.String())
assert.NotContains(t, res.Body.String(), "model_costs")
for _, secret := range []string{"internal", "secret-alias", "private-channel", "private-key", "disabled-model", "disabled-ability"} {
assert.NotContains(t, res.Body.String(), secret)
}
assert.Contains(t, res.Body.String(), "#688")
fallback := model.DetectionRun{ID:"fallback-completed",RequestKey:"fallback-completed",Kind:"pelican",ChannelID:1,GroupName:"internal",Status:"running",Quality:"normal",HTML:"<html>pelican</html>",CreatedAt:now,Deadline:now+600}
require.NoError(t,db.Create(&fallback).Error)
require.NoError(t,db.Create(&model.DetectionCall{ID:fallback.ID,RunID:fallback.ID,SecretHash:"fallback-only-test",State:"finished"}).Error)
processLocalPelican(context.Background(),fallback)
require.NoError(t,db.First(&fallback,"id = ?",fallback.ID).Error)
assert.Equal(t,"succeeded",fallback.Status)
assert.Equal(t,"unknown",fallback.Quality)
assert.Equal(t,"<html>pelican</html>",fallback.HTML)
assert.True(t,fallback.Settled)
require.NoError(t,db.Delete(&model.DetectionCall{},"id = ?",fallback.ID).Error)
require.NoError(t,db.Delete(&model.DetectionRun{},"id = ?",fallback.ID).Error)
assert.Contains(t, res.Body.String(), `"group_ratio":0.25`)
require.NoError(t, model.SaveGroupMetadata(context.Background(), map[string]model.GroupMetadataValue{"internal": {Tags: []string{"打野", "不稳定"}}}))
service.InvalidateGroupQualityCache()
visitorSite := request("GET", "/api/group-status", "", "", "")
require.Equal(t, 200, visitorSite.Code)
var siteData struct {
Data []UptimeGroupResult `json:"data"`
}
require.NoError(t, common.Unmarshal(visitorSite.Body.Bytes(), &siteData))
require.NotEmpty(t, siteData.Data)
for _, monitor := range siteData.Data[0].Monitors {
if monitor.Group == "internal" {
require.Equal(t, []string{"打野", "不稳定"}, monitor.Tags)
require.NotNil(t, monitor.ModelCosts[fingerprint.Model].USDPerMillion)
assert.Equal(t, int64(1), monitor.ModelCosts[fingerprint.Model].Requests)
}
if monitor.Group == "shared" {
assert.Empty(t, monitor.ModelCosts, "shared channel membership must not copy another group's billed usage")
}
}
assert.NotContains(t, request("GET", "/api/uptime/status", "", "", "").Body.String(), "model_costs")
assert.Contains(t, visitorSite.Body.String(), `"group":"hidden"`)
assert.Contains(t, visitorSite.Body.String(), `"name":"internal"`)
assert.NotContains(t, visitorSite.Body.String(), "#688")
@@ -171,9 +185,9 @@ func TestStatusPageDatabaseMatrix(t *testing.T) {
}
assert.Empty(t, byGroup["internal"].Fingerprint.History)
assert.Nil(t, byGroup["internal"].Fingerprint.Rate)
assert.Empty(t, byGroup["internal"].Pelican.History)
assert.Empty(t, byGroup["internal"].Benchmark.History)
assert.Len(t, byGroup["shared"].Fingerprint.History, 1)
assert.Len(t, byGroup["shared"].Pelican.History, 2)
assert.Len(t, byGroup["shared"].Benchmark.History, 2)
assert.True(t, byGroup["hidden"].Hidden)
assert.Equal(t, []string{fingerprint.Model}, byGroup["internal"].Models)
require.NoError(t, model.SaveStatusPageGroup(context.Background(), "internal", &model.StatusPageGroup{Alias: "#689", Hidden: true}))
@@ -187,13 +201,13 @@ func TestStatusPageDatabaseMatrix(t *testing.T) {
require.NoError(t, db.First(&retained, "id = ?", "old-work").Error)
assert.Equal(t, 17, retained.Charged)
assert.Equal(t, "<h1>art</h1>", retained.HTML)
require.NoError(t, db.Create(&model.DetectionRun{ID: "new-work", RequestKey: "new-work", ChannelID: 1, Kind: "pelican", Status: "succeeded", Quality: "normal", CreatedAt: settings["internal"].ResetBefore + 1, Settled: true}).Error)
require.NoError(t, db.Create(&model.DetectionRun{ID: "new-work", RequestKey: "new-work", ChannelID: 1, Kind: "benchmark", Status: "succeeded", Quality: "normal", CreatedAt: settings["internal"].ResetBefore + 1, Settled: true}).Error)
groups, err = currentGroupMonitors(context.Background(), true)
require.NoError(t, err)
for _, m := range groups[0].Monitors {
if m.Group == "internal" {
require.Len(t, m.Pelican.History, 1)
assert.Equal(t, "new-work", m.Pelican.History[0].ID)
require.Len(t, m.Benchmark.History, 1)
assert.Equal(t, "new-work", m.Benchmark.History[0].ID)
}
}
})