feat: sync deployed v1.0.0-rc.38-custom.67
This commit is contained in:
1 parent
d09216bbde
commit
8e6ed0317b
154 files changed
+39540
-20509
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user