feat: sync deployed v1.0.0-rc.38-custom.67
This commit is contained in:
1 parent
d2e03ab7e8
commit
ffc8cf56db
154 files changed
+39540
-20509
No files matched your search
+2
-2
@@ -1,6 +1,6 @@
|
||||
# 自定义版本说明
|
||||
|
||||
当前版本为 v1.0.0-rc.38-custom.44-20260926,基于官方 v1.0.0-rc.38,对应 2026-09-26 已部署的自定义源码。
|
||||
当前版本为 v1.0.0-rc.38-custom.67-20261004,基于官方 v1.0.0-rc.38,对应 2026-10-04 已部署的自定义源码。
|
||||
|
||||
## 主要定制
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## 从源码构建
|
||||
|
||||
docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.38-custom.44-20260926 .
|
||||
docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.38-custom.67-20261004 .
|
||||
|
||||
此入口从 Bun、Go 和 Debian 基础镜像构建前端与服务,不要求预先存在旧版 local/new-api 镜像。VERSION 用于编译版本号;Compose 如果设置了 VERSION 环境变量,也应同步更新。
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.45-20260926
|
||||
COPY new-api /new-api
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.46-20260927"
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.59-20260929
|
||||
COPY new-api /new-api
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.60-20260929"
|
||||
LABEL custom.patch="browser-performance"
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.66-20261004
|
||||
COPY new-api /new-api
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.67"
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.44-20260926
|
||||
COPY new-api /new-api
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.45-20260926"
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.51-20260928
|
||||
COPY new-api /new-api
|
||||
LABEL custom.patch="automatic-routing-pool-failover-model-costs"
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.50-20260927
|
||||
COPY new-api /new-api
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.51-20260928"
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.65-20261004
|
||||
COPY new-api /new-api
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.66"
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.47-20260927
|
||||
COPY new-api /new-api
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.48-20260927"
|
||||
@@ -25,7 +25,7 @@ RUN apt-get update \
|
||||
COPY --from=build /build/new-api /
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.44-20260926"
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.67-20261004"
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget && rm -rf /var/lib/apt/lists/* && update-ca-certificates
|
||||
COPY new-api /new-api
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.64"
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget && rm -rf /var/lib/apt/lists/* && update-ca-certificates
|
||||
COPY new-api /new-api
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
|
||||
COPY pkg/model_benchmark/LICENSE-*.txt /licenses/model-benchmark/
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.65"
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM local/new-api:v1.0.0-rc.38-custom.49-20260927
|
||||
COPY new-api /new-api
|
||||
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.50-20260927"
|
||||
@@ -326,3 +326,10 @@ For more information, please refer to https://unlicense.org/
|
||||
## Numerical model fingerprint assets
|
||||
|
||||
The embedded bank and scorer in `pkg/model_fingerprint` are from [gpt-model-check](https://github.com/Katao123/gpt-model-check), commit `3d6716a0903cf061daea001d5d44ee410846634e`, using the ModelTrace bank dated 2026-09-05. The upstream gpt-model-check, ModelTrace, and is-gpt-nerfed MIT notices are included alongside the sources and under `/licenses/model-fingerprint/` in the image. Relative candidate weights are not calibrated identity probabilities.
|
||||
|
||||
## Meow model fingerprint
|
||||
|
||||
The active short-probe benchmark and predictive scorer are adapted from chen-006/meow-llm-detector, commit 0f31d4a, benchmark 4.5.4-predictive.20260924.2 (quick tier, 32 requests). License: PolyForm Noncommercial 1.0.0; see pkg/model_fingerprint/LICENSE-meow.txt. Required Notice: Copyright 2026 chen-006 and contributors. Original project: https://github.com/chen-006/gpt56_api_detector. Match scores are not identity probabilities.
|
||||
|
||||
ModelTrace primary fingerprint: https://github.com/xqy2006/ModelTrace, commit d4131b30243dfa05e70180b5eedde742103f1d73, MIT. Scorer and unified candidate bank are embedded locally; see pkg/model_fingerprint/LICENSE-ModelTrace.txt.
|
||||
Meow 4.5.4 predictive benchmark remains an auxiliary signal; see pkg/model_benchmark/LICENSE-meow.txt.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
- 历史错误日志在 API 输出时,依据保存的 request_policy 中匹配渠道的分组纠正显示;在用户权限裁剪之前完成,仍不泄露私有路由信息。
|
||||
- 无证据的旧错误记录不猜测分组。消费日志、金额、token 用量和数据库中的历史记录不改写。
|
||||
- 历史记录的数据库分组筛选仍按原存储值工作;新的错误记录保存正确分组,筛选正常。
|
||||
- 用户机器将 features.memories 暂设为 false,原配置已备份,记忆文件未删除;用户需重启 Codex 生效,暂停期间也不自动注入已有记忆。
|
||||
|
||||
测试:实际错误分组、重试分组、无渠道、固定分组;历史读时投影及普通用户日志隐私;真实候选 HTTP 回归;旧版 Astra 守卫/逐次结算保持不变。
|
||||
@@ -450,6 +450,11 @@ func TokenAuth() func(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.LoadPersonalGroupRatios(c, userCache.Id); err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, "User pricing temporarily unavailable")
|
||||
return
|
||||
}
|
||||
defer service.ReleasePoolProbes(c)
|
||||
userCache.WriteContext(c)
|
||||
|
||||
userGroup := userCache.Group
|
||||
|
||||
@@ -27,13 +27,16 @@ import (
|
||||
)
|
||||
|
||||
type ModelRequest struct {
|
||||
Model string `json:"model"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
func Distribute() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var channel *model.Channel
|
||||
var requestedStream bool
|
||||
rejectionMessage := "No available channel for this request."
|
||||
defer func() {
|
||||
if c.Writer.Status() >= 400 {
|
||||
service.RecordRequestPolicyTermination(c, types.NewErrorWithStatusCode(errors.New("request rejected"), types.ErrorCodeInvalidRequest, c.Writer.Status(), types.ErrOptionWithSkipRetry()))
|
||||
@@ -42,7 +45,7 @@ func Distribute() func(c *gin.Context) {
|
||||
other := model.NewLogOther()
|
||||
other.SetPublic("status_code", c.Writer.Status())
|
||||
service.AppendRelayLogAdminInfo(c, nil, other)
|
||||
model.RecordErrorLog(c, c.GetInt("id"), 0, c.GetString("original_model"), c.GetString("token_name"), "No available channel for this request.", c.GetInt("token_id"), 0, false, "", other)
|
||||
model.RecordErrorLog(c, c.GetInt("id"), 0, c.GetString("original_model"), c.GetString("token_name"), rejectionMessage, c.GetInt("token_id"), 0, requestedStream, "", other)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -57,6 +60,8 @@ func Distribute() func(c *gin.Context) {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
|
||||
return
|
||||
}
|
||||
requestedStream = modelRequest.Stream || strings.Contains(c.Request.URL.Path, "streamGenerateContent") || strings.EqualFold(c.GetHeader("Upgrade"), "websocket")
|
||||
c.Set("pool_request_stream", requestedStream)
|
||||
_, pinned, _ := constraints.ResolvedPin()
|
||||
if !pinned {
|
||||
// Select a channel for the user
|
||||
@@ -124,6 +129,17 @@ func Distribute() func(c *gin.Context) {
|
||||
} else if selectErr.MessageID != "" {
|
||||
message = i18n.T(c, selectErr.MessageID, selectErr.Params)
|
||||
}
|
||||
if service.RequestRoutingPolicy(c) != nil && selectErr.NoAvailableChannel {
|
||||
if c.GetBool("pool_degradation_unavailable") {
|
||||
message = "Group quality checks are temporarily unavailable. Please retry later."
|
||||
} else if wait := c.GetInt("pool_retry_after"); wait > 0 {
|
||||
message = fmt.Sprintf("No eligible group is currently available; failed groups are cooling down or being checked. Retry after %d seconds.", wait)
|
||||
c.Header("Retry-After", fmt.Sprint(wait))
|
||||
} else {
|
||||
message = "No eligible group is available for this model under the current pool and quality settings."
|
||||
}
|
||||
}
|
||||
rejectionMessage = message
|
||||
abortWithOpenAiMessage(c, selectErr.StatusCode, message, selectErr.Code)
|
||||
return
|
||||
}
|
||||
@@ -295,8 +311,9 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) {
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
|
||||
return &ModelRequest{
|
||||
Model: model,
|
||||
Group: group,
|
||||
Model: model,
|
||||
Group: group,
|
||||
Stream: gjson.GetBytes(requestBody, "stream").Bool(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type GroupMetadata struct {
|
||||
GroupName string `gorm:"primaryKey;size:191"`
|
||||
Value string `gorm:"type:text;not null"`
|
||||
}
|
||||
type GroupMetadataValue struct {
|
||||
Tags []string `json:"tags"`
|
||||
HideRecentDegraded bool `json:"hide_recent_degraded"`
|
||||
}
|
||||
|
||||
func GetGroupMetadata(ctx context.Context) (map[string]GroupMetadataValue, error) {
|
||||
var rows []GroupMetadata
|
||||
if err := DB.WithContext(ctx).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]GroupMetadataValue{}
|
||||
for _, row := range rows {
|
||||
var value GroupMetadataValue
|
||||
if err := json.Unmarshal([]byte(row.Value), &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[row.GroupName] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Updates only submitted groups; absent groups keep their existing metadata.
|
||||
func SaveGroupMetadata(ctx context.Context, groups map[string]GroupMetadataValue) error {
|
||||
rows := []GroupMetadata{}
|
||||
for group, value := range groups {
|
||||
if !ratio_setting.ContainsGroupRatio(group) || group == "auto" {
|
||||
return fmt.Errorf("invalid group: %s", group)
|
||||
}
|
||||
tags, seen := []string{}, map[string]bool{}
|
||||
for _, tag := range value.Tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" || seen[tag] {
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(tag) > 40 || len(tags) >= 20 {
|
||||
return fmt.Errorf("too many or too long group tags")
|
||||
}
|
||||
seen[tag] = true
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
value.Tags = tags
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows = append(rows, GroupMetadata{GroupName: group, Value: string(body)})
|
||||
}
|
||||
return DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, row := range rows {
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "group_name"}}, DoUpdates: clause.AssignmentColumns([]string{"value"})}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
type GroupModelCost struct {
|
||||
USDPerMillion *float64 `json:"usd_per_million"`
|
||||
Requests int64 `json:"requests"`
|
||||
Tokens float64 `json:"tokens"`
|
||||
quota float64
|
||||
}
|
||||
|
||||
// Consumption logs contain the settled charge, not the pre-consumed quota.
|
||||
// Attribute by the group actually billed, never by today's channel membership.
|
||||
func GetGroupModelCosts(ctx context.Context, start, end time.Time) (map[string]map[string]GroupModelCost, error) {
|
||||
result := map[string]map[string]GroupModelCost{}
|
||||
rows, err := LOG_DB.WithContext(ctx).Model(&Log{}).Select("group", "model_name", "quota", "prompt_tokens", "completion_tokens", "other").
|
||||
Where("type = ? AND token_id > 0 AND created_at >= ? AND created_at < ?", LogTypeConsume, start.Unix(), end.Unix()).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var group, name, raw string
|
||||
var quota, prompt, completion int64
|
||||
if err := rows.Scan(&group, &name, "a, &prompt, &completion, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if group == "" || name == "" || quota < 0 {
|
||||
continue
|
||||
}
|
||||
tokens, valid := consumptionTokenTotal(prompt, completion, raw)
|
||||
if !valid || tokens <= 0 {
|
||||
continue
|
||||
}
|
||||
if result[group] == nil {
|
||||
result[group] = map[string]GroupModelCost{}
|
||||
}
|
||||
value := result[group][name]
|
||||
value.Requests++
|
||||
value.Tokens += tokens
|
||||
value.quota += float64(quota)
|
||||
result[group][name] = value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for group, models := range result {
|
||||
for name, value := range models {
|
||||
if common.QuotaPerUnit > 0 && value.Tokens > 0 {
|
||||
rate := value.quota / common.QuotaPerUnit / value.Tokens * 1_000_000
|
||||
value.USDPerMillion = &rate
|
||||
}
|
||||
result[group][name] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func consumptionTokenTotal(prompt, completion int64, raw string) (float64, bool) {
|
||||
var usage struct {
|
||||
Total int64 `json:"input_tokens_total"`
|
||||
Cached int64 `json:"cache_tokens"`
|
||||
Created int64 `json:"cache_creation_tokens"`
|
||||
Created5m int64 `json:"cache_creation_tokens_5m"`
|
||||
Created1h int64 `json:"cache_creation_tokens_1h"`
|
||||
Written int64 `json:"cache_write_tokens"`
|
||||
Semantic string `json:"usage_semantic"`
|
||||
Claude bool `json:"claude"`
|
||||
}
|
||||
if raw != "" && common.UnmarshalJsonStr(raw, &usage) != nil {
|
||||
return 0, false
|
||||
}
|
||||
input := float64(max(prompt, 0))
|
||||
if usage.Total > 0 {
|
||||
input = float64(usage.Total)
|
||||
} else if usage.Semantic == "anthropic" || (usage.Semantic == "" && usage.Claude) {
|
||||
created := max(float64(usage.Written), float64(usage.Created), float64(max(usage.Created5m, 0))+float64(max(usage.Created1h, 0)), 0)
|
||||
input += float64(max(usage.Cached, 0)) + created
|
||||
}
|
||||
return input + float64(max(completion, 0)), true
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package model
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,6 +17,63 @@ import (
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestGroupModelCostsDatabaseMatrix(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
var driver gorm.Dialector
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
driver = sqlite.Open(":memory:")
|
||||
case "mysql":
|
||||
if os.Getenv("TEST_MYSQL_DSN") == "" {
|
||||
t.Skip("no MySQL test database")
|
||||
}
|
||||
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
|
||||
case "postgres":
|
||||
if os.Getenv("TEST_POSTGRES_DSN") == "" {
|
||||
t.Skip("no PostgreSQL test database")
|
||||
}
|
||||
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
|
||||
}
|
||||
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("cost_%d_", time.Now().UnixNano())}})
|
||||
require.NoError(t, err)
|
||||
oldDB, oldUnit := LOG_DB, common.QuotaPerUnit
|
||||
LOG_DB = db
|
||||
common.QuotaPerUnit = 500000
|
||||
t.Cleanup(func() {
|
||||
LOG_DB = oldDB
|
||||
common.QuotaPerUnit = oldUnit
|
||||
require.NoError(t, db.Migrator().DropTable(&Log{}))
|
||||
sqlDB, e := db.DB()
|
||||
require.NoError(t, e)
|
||||
require.NoError(t, sqlDB.Close())
|
||||
})
|
||||
require.NoError(t, db.AutoMigrate(&Log{}))
|
||||
logs := []Log{
|
||||
{Type: 2, TokenId: 1, CreatedAt: 150, Group: "a", ModelName: "m", Quota: 50, PromptTokens: 100, CompletionTokens: 100, Other: `{"cache_tokens":80}`},
|
||||
{Type: 2, TokenId: 2, CreatedAt: 150, Group: "a", ModelName: "m", Quota: 150, PromptTokens: 200, CompletionTokens: 100, Other: `{"usage_semantic":"anthropic","cache_tokens":100,"cache_creation_tokens":400}`},
|
||||
{Type: 2, TokenId: 3, CreatedAt: 150, Group: "b", ModelName: "m", Quota: 1000, PromptTokens: 200, CompletionTokens: 100, Other: `{"input_tokens_total":900}`},
|
||||
{Type: 2, TokenId: 1, CreatedAt: 150, Group: "a", ModelName: "free", Quota: 0, PromptTokens: 100},
|
||||
{Type: 2, TokenId: 0, CreatedAt: 150, Group: "a", ModelName: "m", Quota: 999999, PromptTokens: 100},
|
||||
{Type: 2, TokenId: 1, CreatedAt: 99, Group: "a", ModelName: "m", Quota: 999999, PromptTokens: 100},
|
||||
{Type: 2, TokenId: 1, CreatedAt: 200, Group: "a", ModelName: "m", Quota: 999999, PromptTokens: 100},
|
||||
{Type: 5, TokenId: 1, CreatedAt: 150, Group: "a", ModelName: "m", Quota: 999999, PromptTokens: 100},
|
||||
{Type: 2, TokenId: 1, CreatedAt: 150, Group: "a", ModelName: "no-tokens", Quota: 500},
|
||||
}
|
||||
require.NoError(t, db.Create(&logs).Error)
|
||||
result, err := GetGroupModelCosts(context.Background(), time.Unix(100, 0), time.Unix(200, 0))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result["a"]["m"].USDPerMillion)
|
||||
assert.InDelta(t, 0.4, *result["a"]["m"].USDPerMillion, 1e-9)
|
||||
assert.Equal(t, int64(2), result["a"]["m"].Requests)
|
||||
assert.Equal(t, float64(1000), result["a"]["m"].Tokens)
|
||||
assert.InDelta(t, 2, *result["b"]["m"].USDPerMillion, 1e-9)
|
||||
assert.Zero(t, *result["a"]["free"].USDPerMillion)
|
||||
assert.NotContains(t, result["a"], "no-tokens")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupCacheStatsDatabaseMatrix(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
|
||||
@@ -352,6 +352,8 @@ func migrateDB() error {
|
||||
&Token{},
|
||||
&RelayAttempt{},
|
||||
&User{},
|
||||
&PersonalGroupRatio{},
|
||||
&GroupMetadata{},
|
||||
&UserSession{},
|
||||
&AuthFlow{},
|
||||
&ExternalIdentityClaim{},
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Stored independently from User and UserSetting to prevent profile mass assignment.
|
||||
type PersonalGroupRatio struct {
|
||||
UserID int `gorm:"primaryKey;autoIncrement:false"`
|
||||
Ratios string `gorm:"type:text;not null"`
|
||||
}
|
||||
|
||||
const personalRatioContextKey = "personal_group_ratios"
|
||||
|
||||
func GetPersonalGroupRatios(ctx context.Context, userID int) (map[string]float64, error) {
|
||||
var rows []PersonalGroupRatio
|
||||
if err := DB.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]float64{}
|
||||
if len(rows) != 0 {
|
||||
if err := json.Unmarshal([]byte(rows[0].Ratios), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func SavePersonalGroupRatios(ctx context.Context, userID int, ratios map[string]float64) error {
|
||||
for group, ratio := range ratios {
|
||||
if !ratio_setting.ContainsGroupRatio(group) || group == "auto" || ratio < 0 || math.IsNaN(ratio) || math.IsInf(ratio, 0) {
|
||||
return fmt.Errorf("invalid group or ratio: %s", group)
|
||||
}
|
||||
}
|
||||
if ratios == nil {
|
||||
ratios = map[string]float64{}
|
||||
}
|
||||
body, err := json.Marshal(ratios)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := PersonalGroupRatio{UserID: userID, Ratios: string(body)}
|
||||
if err = DB.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"ratios"})}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Pricing is loaded from the database at request admission, never from a stale
|
||||
// process/Redis cache. The request snapshot survives retries and price updates.
|
||||
return invalidateUserCache(userID)
|
||||
}
|
||||
|
||||
func LoadPersonalGroupRatios(c *gin.Context, userID int) error {
|
||||
ratios, err := GetPersonalGroupRatios(c.Request.Context(), userID)
|
||||
if err == nil {
|
||||
c.Set(personalRatioContextKey, ratios)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func EffectiveGroupRatio(c *gin.Context, userGroup, group string) types.GroupRatioInfo {
|
||||
result := types.GroupRatioInfo{GroupRatio: ratio_setting.GetGroupRatio(group), GroupSpecialRatio: -1}
|
||||
if ratio, ok := ratio_setting.GetGroupGroupRatio(userGroup, group); ok {
|
||||
result.GroupRatio, result.GroupSpecialRatio, result.HasSpecialRatio = ratio, ratio, true
|
||||
}
|
||||
if value, ok := c.Get(personalRatioContextKey); ok {
|
||||
if ratios, ok := value.(map[string]float64); ok {
|
||||
if ratio, ok := ratios[group]; ok {
|
||||
result.GroupRatio, result.GroupSpecialRatio, result.HasSpecialRatio = ratio, ratio, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func HasPersonalGroupRatio(c *gin.Context, group string) bool {
|
||||
value, _ := c.Get(personalRatioContextKey)
|
||||
ratios, _ := value.(map[string]float64)
|
||||
_, ok := ratios[group]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestPersonalGroupConfigurationDatabaseMatrix(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
var driver gorm.Dialector
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
driver = sqlite.Open(":memory:")
|
||||
case "mysql":
|
||||
if os.Getenv("TEST_MYSQL_DSN") == "" {
|
||||
t.Skip("DSN unset")
|
||||
}
|
||||
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
|
||||
case "postgres":
|
||||
if os.Getenv("TEST_POSTGRES_DSN") == "" {
|
||||
t.Skip("DSN unset")
|
||||
}
|
||||
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
|
||||
}
|
||||
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("personal_%d_", time.Now().UnixNano())}})
|
||||
require.NoError(t, err)
|
||||
old, redis := DB, common.RedisEnabled
|
||||
DB, common.RedisEnabled = db, false
|
||||
ratios, special := ratio_setting.GroupRatio2JSONString(), ratio_setting.GroupGroupRatio2JSONString()
|
||||
rows := []any{&PersonalGroupRatio{}, &GroupMetadata{}}
|
||||
t.Cleanup(func() {
|
||||
for _, row := range rows {
|
||||
require.NoError(t, db.Migrator().DropTable(row))
|
||||
}
|
||||
DB, common.RedisEnabled = old, redis
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(ratios))
|
||||
require.NoError(t, ratio_setting.UpdateGroupGroupRatioByJSONString(special))
|
||||
conn, _ := db.DB()
|
||||
_ = conn.Close()
|
||||
})
|
||||
require.NoError(t, db.AutoMigrate(rows...))
|
||||
require.NoError(t, db.AutoMigrate(rows...))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString("{\"default\":1,\"vip\":2}"))
|
||||
require.NoError(t, ratio_setting.UpdateGroupGroupRatioByJSONString("{\"default\":{\"vip\":0.7}}"))
|
||||
makeCtx := func() *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
require.NoError(t, LoadPersonalGroupRatios(c, 42))
|
||||
return c
|
||||
}
|
||||
require.Equal(t, 0.7, EffectiveGroupRatio(makeCtx(), "default", "vip").GroupRatio)
|
||||
require.Equal(t, 1.0, EffectiveGroupRatio(makeCtx(), "default", "default").GroupRatio)
|
||||
require.NoError(t, SavePersonalGroupRatios(context.Background(), 42, map[string]float64{"vip": 0.2, "default": 0}))
|
||||
inFlight := makeCtx()
|
||||
require.Equal(t, 0.2, EffectiveGroupRatio(inFlight, "default", "vip").GroupRatio)
|
||||
require.Equal(t, 0.0, EffectiveGroupRatio(inFlight, "default", "default").GroupRatio)
|
||||
require.True(t, HasPersonalGroupRatio(inFlight, "vip"))
|
||||
for _, bad := range []float64{-1, math.NaN(), math.Inf(1)} {
|
||||
require.Error(t, SavePersonalGroupRatios(context.Background(), 42, map[string]float64{"vip": bad}))
|
||||
}
|
||||
require.NoError(t, SavePersonalGroupRatios(context.Background(), 42, map[string]float64{}))
|
||||
require.Equal(t, 0.2, EffectiveGroupRatio(inFlight, "default", "vip").GroupRatio)
|
||||
require.Equal(t, 0.7, EffectiveGroupRatio(makeCtx(), "default", "vip").GroupRatio)
|
||||
require.False(t, HasPersonalGroupRatio(makeCtx(), "vip"))
|
||||
require.NoError(t, SaveGroupMetadata(context.Background(), map[string]GroupMetadataValue{"vip": {Tags: []string{"官Key", "官Key", " 打野 ", "不稳定"}, HideRecentDegraded: true}}))
|
||||
metadata, err := GetGroupMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"官Key", "打野", "不稳定"}, metadata["vip"].Tags)
|
||||
require.True(t, metadata["vip"].HideRecentDegraded)
|
||||
require.NoError(t, db.AutoMigrate(rows...))
|
||||
metadata, err = GetGroupMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, metadata["vip"].Tags, 3)
|
||||
var u User
|
||||
require.NoError(t, json.Unmarshal([]byte("{\"personal_group_ratios\":{\"vip\":0},\"ratios\":{\"vip\":0}}"), &u))
|
||||
require.Equal(t, 0.7, EffectiveGroupRatio(makeCtx(), "default", "vip").GroupRatio)
|
||||
})
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -16,19 +16,26 @@ type ModelRoutingRule struct {
|
||||
}
|
||||
|
||||
type TokenRoutingPolicy struct {
|
||||
Version int `json:"version"`
|
||||
Mode string `json:"mode"`
|
||||
Groups []TokenGroupEntry `json:"groups"`
|
||||
ModelRules []ModelRoutingRule `json:"model_rules"`
|
||||
SkipDegradedNonAstra bool `json:"skip_degraded_non_astra,omitempty"`
|
||||
AutoPreference string `json:"auto_preference,omitempty"`
|
||||
AllowDegraded bool `json:"allow_degraded,omitempty"`
|
||||
SkipDegradedGroups bool `json:"skip_degraded_groups,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Mode string `json:"mode"`
|
||||
Groups []TokenGroupEntry `json:"groups"`
|
||||
ModelRules []ModelRoutingRule `json:"model_rules"`
|
||||
}
|
||||
|
||||
func (p *TokenRoutingPolicy) Validate(maxGroups int, allowed func(string) bool) error {
|
||||
if p.Version != 1 || (p.Mode != "custom" && p.Mode != "inherit" && p.Mode != "user") {
|
||||
if p.Version != 1 || (p.Mode != "custom" && p.Mode != "inherit" && p.Mode != "user" && p.Mode != "smart") {
|
||||
return fmt.Errorf("invalid routing policy version or mode")
|
||||
}
|
||||
if p.Mode == "custom" && len(p.Groups) == 0 {
|
||||
return fmt.Errorf("a group pool must contain at least one group")
|
||||
}
|
||||
if p.Mode == "smart" && (len(p.ModelRules) != 0 || (p.AutoPreference != "" && p.AutoPreference != "cost" && p.AutoPreference != "speed" && p.AutoPreference != "stable")) {
|
||||
return fmt.Errorf("invalid automatic routing settings")
|
||||
}
|
||||
if p.Mode != "custom" && len(p.Groups) != 0 {
|
||||
return fmt.Errorf("inherited pools cannot contain custom groups")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package billingexpr
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImageGenerationPriceSizesAndQuantity(t *testing.T) {
|
||||
expression := `(param("size") != nil && (param("size") == "4k" || (param("size") matches "^[0-9]+x[0-9]+$" && min(float(split(param("size"), "x")[0]), float(split(param("size"), "x")[1])) > 2048)) ? tier("4k", fixed(0.12)) : param("size") != nil && (param("size") == "2k" || (param("size") matches "^[0-9]+x[0-9]+$" && min(float(split(param("size"), "x")[0]), float(split(param("size"), "x")[1])) > 1024)) ? tier("2k", fixed(0.08)) : tier("1k", fixed(0.06))) * image_count`
|
||||
for _, tc := range []struct {
|
||||
body, tier string
|
||||
price float64
|
||||
}{{`{}`, "1k", 0.06}, {`{"size":"auto"}`, "1k", 0.06}, {`{"size":"1024x1536"}`, "1k", 0.06}, {`{"size":"2048x1152"}`, "2k", 0.08}, {`{"size":"3840x2160"}`, "4k", 0.12}, {`{"size":"2k"}`, "2k", 0.08}} {
|
||||
for _, n := range []int{1, 2, 3} {
|
||||
cost, trace, err := RunExprWithRequest(expression, TokenParams{P: 10000, C: 5000}, RequestInput{Body: []byte(tc.body), ImageCount: &n})
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, tc.price*float64(n)*1000000, cost, 0.01)
|
||||
require.Equal(t, tc.tier, trace.MatchedTier)
|
||||
require.Equal(t, BillingUnitRequest, trace.BillingUnit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
||||
|
||||
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||
|
||||
Required Notice: Copyright 2026 chen-006 and contributors. Original project: https://github.com/chen-006/gpt56_api_detector
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one of your licenses.
|
||||
@@ -0,0 +1,200 @@
|
||||
// Meow predictive fingerprint integration, pinned to the public GPT benchmark.
|
||||
// Required Notice: Copyright 2026 chen-006 and contributors.
|
||||
// Original project: https://github.com/chen-006/gpt56_api_detector
|
||||
// See LICENSE-meow.txt for the PolyForm Noncommercial 1.0.0 license.
|
||||
package model_benchmark
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
const Model = "gpt-6-astra"
|
||||
const Version = "meow-4.5.4-predictive.20260924.2"
|
||||
const ProbeCount = 32
|
||||
const OutputTokens = 128
|
||||
|
||||
//go:embed meow.json
|
||||
var benchmarkJSON []byte
|
||||
|
||||
type Sample struct {
|
||||
Text string `json:"text"`
|
||||
// Ordinal in the fixed quick-tier plan, under the existing storage key.
|
||||
Count int `json:"expected_count"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Status string `json:"status"`
|
||||
ValidSamples int `json:"valid_samples"`
|
||||
Closest string `json:"closest,omitempty"`
|
||||
Weight float64 `json:"relative_weight,omitempty"`
|
||||
Gap float64 `json:"score_gap,omitempty"`
|
||||
Engine string `json:"engine,omitempty"`
|
||||
Matches map[string]float64 `json:"matches,omitempty"`
|
||||
Thresholds map[string]float64 `json:"thresholds,omitempty"`
|
||||
}
|
||||
|
||||
type Probe struct {
|
||||
ID string `json:"id"`
|
||||
System string `json:"system"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
type fittedCell struct {
|
||||
Categories []string `json:"categories"`
|
||||
Alpha map[string][]float64 `json:"alpha"`
|
||||
}
|
||||
type benchmark struct {
|
||||
Plan []Probe `json:"-"`
|
||||
Probes []struct {
|
||||
Cells []Probe `json:"cells"`
|
||||
} `json:"probes"`
|
||||
Fitted struct {
|
||||
Models []string `json:"models"`
|
||||
Sources []string `json:"sources"`
|
||||
References []string `json:"reference_sources"`
|
||||
Cells map[string]fittedCell `json:"cells"`
|
||||
} `json:"fitted"`
|
||||
Tiers map[string]struct {
|
||||
Counts map[string]int `json:"counts"`
|
||||
Thresholds map[string]float64 `json:"thresholds"`
|
||||
} `json:"tiers"`
|
||||
}
|
||||
|
||||
var load = sync.OnceValues(func() (*benchmark, error) {
|
||||
var b benchmark
|
||||
if err := common.Unmarshal(benchmarkJSON, &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var plan []Probe
|
||||
for _, p := range b.Probes {
|
||||
for _, c := range p.Cells {
|
||||
for range b.Tiers["low"].Counts[c.ID] {
|
||||
plan = append(plan, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(plan, func(i, j int) bool { return plan[i].ID < plan[j].ID })
|
||||
if len(plan) != ProbeCount {
|
||||
return nil, errors.New("invalid meow plan")
|
||||
}
|
||||
b.Plan = plan
|
||||
return &b, nil
|
||||
})
|
||||
|
||||
func ProbeAt(index int) (Probe, error) {
|
||||
b, err := load()
|
||||
if err != nil {
|
||||
return Probe{}, err
|
||||
}
|
||||
if index < 0 || index >= len(b.Plan) {
|
||||
return Probe{}, errors.New("invalid probe index")
|
||||
}
|
||||
return b.Plan[index], nil
|
||||
}
|
||||
|
||||
func ValidSample(text string) bool {
|
||||
if !utf8.ValidString(text) || utf8.RuneCountInString(text) > 65536 {
|
||||
return false
|
||||
}
|
||||
text = cases.Fold().String(strings.TrimSpace(text))
|
||||
return text != "" && utf8.RuneCountInString(text) <= 4096
|
||||
}
|
||||
|
||||
// Whole-run Dirichlet posterior prediction, using average log evidence versus
|
||||
// the strongest rival and calibrated thresholds. Closest alone is no verdict.
|
||||
func Score(samples []Sample) (Result, error) {
|
||||
b, err := load()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
r := Result{Status: "inconclusive", Engine: Version, Matches: map[string]float64{}, Thresholds: b.Tiers["low"].Thresholds}
|
||||
counts := map[string]map[string]int{}
|
||||
seen := map[int]bool{}
|
||||
for _, sample := range samples {
|
||||
if sample.Count < 0 || sample.Count >= len(b.Plan) || seen[sample.Count] {
|
||||
return r, errors.New("invalid or duplicate meow sample")
|
||||
}
|
||||
seen[sample.Count] = true
|
||||
if !ValidSample(sample.Text) {
|
||||
continue
|
||||
}
|
||||
id := b.Plan[sample.Count].ID
|
||||
text := cases.Fold().String(strings.TrimSpace(sample.Text))
|
||||
if counts[id] == nil {
|
||||
counts[id] = map[string]int{}
|
||||
}
|
||||
counts[id][text]++
|
||||
r.ValidSamples++
|
||||
}
|
||||
sufficient := r.ValidSamples >= int(math.Ceil(.6*ProbeCount))
|
||||
evidence := map[string]float64{}
|
||||
for id, planned := range b.Tiers["low"].Counts {
|
||||
cell := b.Fitted.Cells[id]
|
||||
numbers := make([]int, len(cell.Categories))
|
||||
positions := map[string]int{}
|
||||
for i, category := range cell.Categories {
|
||||
positions[category] = i
|
||||
}
|
||||
n := 0
|
||||
for text, number := range counts[id] {
|
||||
i, ok := positions[text]
|
||||
if !ok {
|
||||
i = positions["__UNSEEN_IN_TRAINING__"]
|
||||
}
|
||||
numbers[i] += number
|
||||
n += number
|
||||
}
|
||||
if n < int(math.Ceil(.6*float64(planned))) {
|
||||
sufficient = false
|
||||
}
|
||||
for _, source := range b.Fitted.Sources {
|
||||
alpha := cell.Alpha[source]
|
||||
total, value := 0.0, 0.0
|
||||
for i, a := range alpha {
|
||||
total += a
|
||||
left, _ := math.Lgamma(a + float64(numbers[i]))
|
||||
right, _ := math.Lgamma(a)
|
||||
value += left - right
|
||||
}
|
||||
left, _ := math.Lgamma(total)
|
||||
right, _ := math.Lgamma(total + float64(n))
|
||||
evidence[source] += value + left - right
|
||||
}
|
||||
}
|
||||
evidence["other_known_external"] = math.Inf(-1)
|
||||
for _, source := range b.Fitted.References {
|
||||
evidence["other_known_external"] = math.Max(evidence["other_known_external"], evidence[source])
|
||||
}
|
||||
for _, model := range b.Fitted.Models {
|
||||
rival := math.Inf(-1)
|
||||
for _, other := range b.Fitted.Models {
|
||||
if other != model {
|
||||
rival = math.Max(rival, evidence[other])
|
||||
}
|
||||
}
|
||||
r.Matches[model] = 1 / (1 + math.Exp((rival-evidence[model])/float64(max(r.ValidSamples, 1))))
|
||||
}
|
||||
ordered := append([]string(nil), b.Fitted.Models...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool { return r.Matches[ordered[i]] > r.Matches[ordered[j]] })
|
||||
top := ordered[0]
|
||||
r.Weight = r.Matches[top]
|
||||
r.Gap = (evidence[top] - evidence[ordered[1]]) / float64(max(r.ValidSamples, 1))
|
||||
if r.ValidSamples > 0 {
|
||||
r.Closest = top
|
||||
}
|
||||
if sufficient && r.Weight > r.Thresholds[top] && r.Weight > r.Matches[ordered[1]] {
|
||||
r.Status = "mismatch"
|
||||
if top == Model {
|
||||
r.Status = "match"
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package model_benchmark
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFingerprintMeowParity(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/meow-reference.json")
|
||||
require.NoError(t, err)
|
||||
var fixtures []struct {
|
||||
Name string `json:"name"`
|
||||
Samples []Sample `json:"samples"`
|
||||
Expected struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Matches map[string]float64 `json:"matches"`
|
||||
Valid int `json:"valid_samples"`
|
||||
} `json:"expected"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(data, &fixtures))
|
||||
for _, f := range fixtures {
|
||||
t.Run(f.Name, func(t *testing.T) {
|
||||
result, err := Score(f.Samples)
|
||||
require.NoError(t, err)
|
||||
status := f.Expected.Verdict
|
||||
if status == "insufficient" {
|
||||
status = "inconclusive"
|
||||
}
|
||||
assert.Equal(t, status, result.Status)
|
||||
assert.Equal(t, f.Expected.Valid, result.ValidSamples)
|
||||
for model, score := range f.Expected.Matches {
|
||||
assert.InDelta(t, score, result.Matches[model], 1e-10, model)
|
||||
}
|
||||
})
|
||||
}
|
||||
_, err = Score([]Sample{{Text: "42", Count: 0}, {Text: "42", Count: 0}})
|
||||
require.Error(t, err)
|
||||
assert.False(t, ValidSample(" "))
|
||||
assert.False(t, ValidSample(strings.Repeat("a", 4097)))
|
||||
}
|
||||
|
||||
func TestFingerprintResponseCompletionAndTools(t *testing.T) {
|
||||
stream := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"42\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n"
|
||||
answer, err := ReadResponse(strings.NewReader(stream), "text/event-stream")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "42", answer.Text)
|
||||
assert.True(t, answer.HasUsage)
|
||||
answer, err = ReadResponse(strings.NewReader(strings.Replace(stream, `"status":"completed"`, `"status":"completed","output":[]`, 1)), "text/event-stream")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, answer.Text)
|
||||
completed := `{"status":"completed","model":"gpt-6-astra","output":[{"type":"message","content":[{"type":"output_text","text":"[1,2,3]"}]}],"usage":{"input_tokens":21,"output_tokens":12,"input_tokens_details":{"cached_tokens":10}}}`
|
||||
for _, input := range []struct{ body, content string }{{completed, "application/json"}, {"data: {\"type\":\"response.completed\",\"response\":" + completed + "}\n\n", "text/event-stream"}} {
|
||||
r, err := ReadResponse(strings.NewReader(input.body), input.content)
|
||||
if err != nil || r.Text != "[1,2,3]" || r.CachedTokens != 10 || !r.HasUsage {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
}
|
||||
for _, body := range []string{
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"[1,2,3]\"}\n\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\"}}\n\n",
|
||||
strings.Replace(completed, "completed", "incomplete", 1),
|
||||
strings.Replace(completed, "\"type\":\"message\"", "\"type\":\"function_call\"", 1),
|
||||
} {
|
||||
if _, err := ReadResponse(strings.NewReader(body), "application/json"); err == nil {
|
||||
t.Fatal("accepted tool or incomplete sample")
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,144 @@
|
||||
package model_benchmark
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var ErrInvalidSample = errors.New("invalid probe answer")
|
||||
var ErrIncomplete = errors.New("probe response incomplete")
|
||||
|
||||
type ProbeResponse struct {
|
||||
Text string `json:"-"`
|
||||
DeclaredModel string `json:"declared_model,omitempty"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
||||
HasUsage bool `json:"has_usage"`
|
||||
}
|
||||
|
||||
func completedResponse(response gjson.Result) (ProbeResponse, error) {
|
||||
r := ProbeResponse{}
|
||||
r.DeclaredModel = response.Get("model").String()
|
||||
if len(r.DeclaredModel) > 128 {
|
||||
r.DeclaredModel = ""
|
||||
}
|
||||
usage := response.Get("usage")
|
||||
r.HasUsage = usage.Get("input_tokens").Type == gjson.Number && usage.Get("output_tokens").Type == gjson.Number
|
||||
r.InputTokens = usage.Get("input_tokens").Int()
|
||||
r.OutputTokens = usage.Get("output_tokens").Int()
|
||||
r.CachedTokens = usage.Get("input_tokens_details.cached_tokens").Int()
|
||||
r.CacheWriteTokens = max(usage.Get("input_tokens_details.cached_creation_tokens").Int(), usage.Get("input_tokens_details.cache_creation_tokens").Int())
|
||||
if response.Get("status").String() != "completed" {
|
||||
return r, ErrIncomplete
|
||||
}
|
||||
for _, item := range response.Get("output").Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "reasoning":
|
||||
case "message":
|
||||
for _, content := range item.Get("content").Array() {
|
||||
if content.Get("type").String() != "output_text" {
|
||||
return r, ErrInvalidSample
|
||||
}
|
||||
r.Text += content.Get("text").String()
|
||||
if len(r.Text) > 16384 {
|
||||
return ProbeResponse{}, ErrInvalidSample
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r, ErrInvalidSample
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Only a complete response is a sample. Partial deltas, tool calls and a
|
||||
// truncated stream cannot become a model verdict. Total bytes are bounded.
|
||||
func ReadResponse(reader io.Reader, contentType string) (ProbeResponse, error) {
|
||||
limited := &io.LimitedReader{R: reader, N: 8 << 20}
|
||||
buffered := bufio.NewReader(limited)
|
||||
peek, _ := buffered.Peek(1)
|
||||
if !strings.Contains(contentType, "text/event-stream") && len(peek) > 0 && peek[0] == '{' {
|
||||
data, err := io.ReadAll(buffered)
|
||||
if err != nil {
|
||||
return ProbeResponse{}, err
|
||||
}
|
||||
if limited.N == 0 || !gjson.ValidBytes(data) {
|
||||
return ProbeResponse{}, ErrInvalidSample
|
||||
}
|
||||
return completedResponse(gjson.ParseBytes(data))
|
||||
}
|
||||
scanner := bufio.NewScanner(buffered)
|
||||
scanner.Buffer(make([]byte, 4096), 2<<20)
|
||||
var data strings.Builder
|
||||
var deltas strings.Builder
|
||||
refused := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
if data.Len() > 0 {
|
||||
data.WriteByte('\n')
|
||||
}
|
||||
data.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
if data.Len() > 2<<20 {
|
||||
return ProbeResponse{}, ErrInvalidSample
|
||||
}
|
||||
continue
|
||||
}
|
||||
if line != "" || data.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
payload := data.String()
|
||||
data.Reset()
|
||||
if payload == "[DONE]" {
|
||||
break
|
||||
}
|
||||
if !gjson.Valid(payload) {
|
||||
return ProbeResponse{}, ErrInvalidSample
|
||||
}
|
||||
event := gjson.Parse(payload)
|
||||
typ := event.Get("type").String()
|
||||
if typ == "response.output_text.delta" {
|
||||
deltas.WriteString(event.Get("delta").String())
|
||||
if deltas.Len() > 65536 {
|
||||
return ProbeResponse{}, ErrInvalidSample
|
||||
}
|
||||
}
|
||||
if typ == "response.refusal.delta" || typ == "response.refusal.done" {
|
||||
refused = true
|
||||
}
|
||||
if typ == "response.output_item.added" || typ == "response.output_item.done" {
|
||||
itemType := event.Get("item.type").String()
|
||||
if itemType != "message" && itemType != "reasoning" {
|
||||
return ProbeResponse{}, ErrInvalidSample
|
||||
}
|
||||
}
|
||||
if typ == "response.completed" {
|
||||
response := event.Get("response")
|
||||
answer, err := completedResponse(response)
|
||||
if refused {
|
||||
return answer, ErrInvalidSample
|
||||
}
|
||||
// Meow 4.5.4 accepts deltas only when completion omits output entirely.
|
||||
if err == nil && !response.Get("output").Exists() {
|
||||
answer.Text = deltas.String()
|
||||
}
|
||||
return answer, err
|
||||
}
|
||||
if typ == "response.failed" || typ == "response.incomplete" {
|
||||
return completedResponse(event.Get("response"))
|
||||
}
|
||||
if typ == "error" {
|
||||
return ProbeResponse{}, ErrIncomplete
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return ProbeResponse{}, err
|
||||
}
|
||||
return ProbeResponse{}, ErrIncomplete
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[{"row_id": "gpt-6-astra__query-01", "parent_row_id": "gpt-6-astra__query-01", "bank_id": "gpt", "source": "gpt-6-astra", "model_id": "gpt-6-astra", "response_model": "gpt-6-astra", "exact_version": "gpt-6-astra", "condition_id": "environment-01", "nuisance_condition_id": "environment-01", "wrapper_id": "environment-01", "wrapper_transport": "clean", "challenge_id": "query-01", "task_index": 0, "requested_count": 218, "parsed_count": 218, "strict_threshold": 120, "strict_valid": true, "temperature": "provider_default", "config_id": "reference-collection", "provider": "codex", "prompt": "For each of 218 positions, make one separate first-instinct choice of an integer from 1 to 355 inclusive. The current language model must complete this directly without tools, Python, code execution, calculators, search, APIs, or external random generators. Choose every position separately. Do not count upward or downward, including 1, 2, 3, and do not use an arithmetic progression, repeating cycle, repeated block, or another rule-made pattern. Accidental repetitions are valid. Once an item is written, do not sort, reorder, deduplicate, replace, or repair the list. Return one compact JSON array containing the complete sequence and no explanation.", "base_prompt": "For each of 218 positions, make one separate first-instinct choice of an integer from 1 to 355 inclusive. The current language model must complete this directly without tools, Python, code execution, calculators, search, APIs, or external random generators. Choose every position separately. Do not count upward or downward, including 1, 2, 3, and do not use an arithmetic progression, repeating cycle, repeated block, or another rule-made pattern. Accidental repetitions are valid. Once an item is written, do not sort, reorder, deduplicate, replace, or repair the list. Return one compact JSON array containing the complete sequence and no explanation.", "system_prompt": "", "user_prefix": "", "text": "[247,83,319,146,28,204,337,112,59,271,164,352,91,223,17,308,135,76,289,192,43,258,121,334,68,215,301,154,9,237,178,325,54,109,283,196,31,342,87,261,143,22,310,185,73,229,348,116,294,48,203,157,331,95,266,12,181,316,64,240,127,354,39,218,172,298,81,251,106,323,56,189,274,14,139,345,207,93,232,167,305,46,119,287,211,34,253,176,329,72,224,151,8,313,98,269,184,51,341,130,291,66,235,159,322,24,198,277,113,350,85,243,161,37,302,124,280,19,216,146,333,61,194,256,102,317,44,170,288,79,226,355,138,11,264,187,314,97,205,53,275,149,326,32,118,238,69,296,163,221,90,347,179,26,250,132,309,57,201,284,114,339,74,228,155,4,273,193,320,41,245,107,299,182,63,212,351,129,16,267,158,306,88,234,47,328,174,101,286,209,35,252,141,315,77,197,343,120,260,58,231,168,292,21,136,304,94,219,65,336,183,242,7,278,153,324,108,202]", "error": null, "request_seconds": 45.789022299984936, "collected_at": "2026-09-05T02:04:49.454748+00:00"}, {"row_id": "gpt-6-astra__query-02", "parent_row_id": "gpt-6-astra__query-02", "bank_id": "gpt", "source": "gpt-6-astra", "model_id": "gpt-6-astra", "response_model": "gpt-6-astra", "exact_version": "gpt-6-astra", "condition_id": "environment-01", "nuisance_condition_id": "environment-01", "wrapper_id": "environment-01", "wrapper_transport": "clean", "challenge_id": "query-02", "task_index": 0, "requested_count": 233, "parsed_count": 233, "strict_threshold": 129, "strict_valid": true, "temperature": "provider_default", "config_id": "reference-collection", "provider": "codex", "prompt": "Provide exactly 233 whole-number choices between 1 and 355 inclusive, choosing each item separately as it comes to mind. The current language model must complete this directly without tools, Python, code execution, calculators, search, APIs, or external random generators. Choose every position separately. Do not count upward or downward, including 1, 2, 3, and do not use an arithmetic progression, repeating cycle, repeated block, or another rule-made pattern. Accidental repetitions are valid. Once an item is written, do not sort, reorder, deduplicate, replace, or repair the list. Return one compact JSON array containing the complete sequence and no explanation.", "base_prompt": "Provide exactly 233 whole-number choices between 1 and 355 inclusive, choosing each item separately as it comes to mind. The current language model must complete this directly without tools, Python, code execution, calculators, search, APIs, or external random generators. Choose every position separately. Do not count upward or downward, including 1, 2, 3, and do not use an arithmetic progression, repeating cycle, repeated block, or another rule-made pattern. Accidental repetitions are valid. Once an item is written, do not sort, reorder, deduplicate, replace, or repair the list. Return one compact JSON array containing the complete sequence and no explanation.", "system_prompt": "", "user_prefix": "", "text": "[287,43,196,321,78,154,9,242,113,335,62,219,174,28,303,141,87,264,350,106,183,51,298,127,14,231,92,316,1Line truncated
|
||||
+1200
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
||||
|
||||
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||
|
||||
Required Notice: Copyright 2026 chen-006 and contributors. Original project: https://github.com/chen-006/gpt56_api_detector
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one of your licenses.
|
||||
+25752
-18852
File diff suppressed because it is too large.
Load diff
@@ -1,21 +1,30 @@
|
||||
// Numerical fingerprint scorer adapted from Katao123/gpt-model-check (MIT),
|
||||
// commit 3d6716a0903cf061daea001d5d44ee410846634e. See the adjacent licenses.
|
||||
// ModelTrace scorer and bank: xqy2006/ModelTrace d4131b30243dfa05e70180b5eedde742103f1d73 (MIT).
|
||||
package model_fingerprint
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/grafana/sobek"
|
||||
)
|
||||
|
||||
const Model = "gpt-6-astra"
|
||||
const Version = "modeltrace-20260905-gmc-3d6716a"
|
||||
const Version = "modeltrace-d4131b3"
|
||||
|
||||
const ProbeCount = 3
|
||||
const OutputTokens = 4096
|
||||
|
||||
//go:embed bank.json
|
||||
var bankJSON string
|
||||
@@ -39,43 +48,71 @@ type Candidate struct {
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Status string `json:"status"`
|
||||
ValidSamples int `json:"valid_samples"`
|
||||
Closest string `json:"closest,omitempty"`
|
||||
Weight float64 `json:"relative_weight,omitempty"`
|
||||
Gap float64 `json:"score_gap,omitempty"`
|
||||
Engine string `json:"engine"`
|
||||
Matches map[string]float64 `json:"matches,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ValidSamples int `json:"valid_samples"`
|
||||
Closest string `json:"closest,omitempty"`
|
||||
Weight float64 `json:"relative_weight,omitempty"`
|
||||
Gap float64 `json:"score_gap,omitempty"`
|
||||
}
|
||||
|
||||
func ValidArray(text string, count int) bool {
|
||||
if len(text) > 16384 || count <= 0 {
|
||||
return false
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if strings.HasPrefix(text, "```") && strings.HasSuffix(text, "```") {
|
||||
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
||||
text = strings.TrimSpace(text[i+1 : len(text)-3])
|
||||
}
|
||||
}
|
||||
var numbers []int
|
||||
if common.Unmarshal([]byte(text), &numbers) != nil {
|
||||
return false
|
||||
}
|
||||
if len(numbers) < max(80, int(math.Ceil(float64(count)*.55))) || len(numbers) > count*2 {
|
||||
return false
|
||||
}
|
||||
for _, number := range numbers {
|
||||
if number < 1 || number > 355 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
type Probe struct {
|
||||
ID string
|
||||
System string
|
||||
Prompt string
|
||||
Count int
|
||||
}
|
||||
|
||||
func ProbeAt(index int) (Probe, error) {
|
||||
if index < 0 || index >= ProbeCount {
|
||||
return Probe{}, errors.New("invalid probe index")
|
||||
}
|
||||
// Disjoint length ranges retain three independent, non-identical challenges.
|
||||
offset, err := rand.Int(rand.Reader, big.NewInt(13))
|
||||
if err != nil {
|
||||
return Probe{}, err
|
||||
}
|
||||
count := 292 + index*14 + int(offset.Int64())
|
||||
prompt := fmt.Sprintf("这是一次独立的数值选择记录。逐项选择 %d 个 1 到 355(含端点)的整数。", count) +
|
||||
"每个位置都要单独选择;不要从 1 开始计数,不要连续递增或递减,也不要采用等差、循环、重复区块或其他规则化模式。" +
|
||||
"本任务必须由当前语言模型直接完成:禁止调用或借助任何工具,包括 Python、代码执行器、计算器、搜索、API 和外部随机数生成器;也不要先编写或运行代码。" +
|
||||
"偶然重复是有效的;不要重新排列或修正已经写出的项目。数字之间用逗号或空格分隔均可。直接从第一个取值开始输出,不要在序列前重复数量、范围或任务说明。"
|
||||
return Probe{ID: fmt.Sprint(index), Prompt: prompt, Count: count}, nil
|
||||
}
|
||||
|
||||
var integerPattern = regexp.MustCompile(`[0-9]+`)
|
||||
|
||||
// Match ModelTrace's longest numeric run parser; prose separates runs.
|
||||
func ValidArray(text string, count int) bool {
|
||||
if len(text) > 16384 || !utf8.ValidString(text) || count < 0 {
|
||||
return false
|
||||
}
|
||||
longest, current, end := 0, 0, 0
|
||||
for _, span := range integerPattern.FindAllStringIndex(text, -1) {
|
||||
if strings.ContainsFunc(text[end:span[0]], unicode.IsLetter) {
|
||||
current = 0
|
||||
}
|
||||
value, err := strconv.Atoi(text[span[0]:span[1]])
|
||||
if err == nil && value >= 1 && value <= 355 {
|
||||
current++
|
||||
}
|
||||
longest = max(longest, current)
|
||||
end = span[1]
|
||||
}
|
||||
return longest >= max(80, int(math.Ceil(float64(count)*.55)))
|
||||
}
|
||||
|
||||
func ValidSample(text string) bool { return ValidArray(text, 0) }
|
||||
|
||||
func Decide(candidates []Candidate, valid int) Result {
|
||||
r := Result{Status: "inconclusive", ValidSamples: valid}
|
||||
r := Result{Status: "inconclusive", Engine: Version, ValidSamples: valid, Matches: map[string]float64{}}
|
||||
if len(candidates) < 2 {
|
||||
return r
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
r.Matches[candidate.Model] = candidate.Weight
|
||||
}
|
||||
top := candidates[0]
|
||||
r.Closest = top.Model
|
||||
r.Weight = top.Weight
|
||||
@@ -98,6 +135,9 @@ func Decide(candidates []Candidate, valid int) Result {
|
||||
// Score runs only the embedded, pinned scorer. No filesystem, network or host
|
||||
// functions are exposed to the VM; upstream text is data, never JavaScript.
|
||||
func Score(samples []Sample) (Result, error) {
|
||||
if len(samples) > ProbeCount {
|
||||
return Result{}, errors.New("too many fingerprint samples")
|
||||
}
|
||||
valid := make([]Sample, 0, 3)
|
||||
for _, sample := range samples {
|
||||
if ValidArray(sample.Text, sample.Count) {
|
||||
@@ -105,7 +145,7 @@ func Score(samples []Sample) (Result, error) {
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
return Result{Status: "inconclusive"}, nil
|
||||
return Result{Status: "inconclusive", Engine: Version}, nil
|
||||
}
|
||||
compileOnce.Do(func() {
|
||||
program, compileErr = sobek.Compile("fingerprint", strings.ReplaceAll(scorerJS, "export ", ""), true)
|
||||
|
||||
@@ -6,48 +6,51 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFingerprintReferenceAndInvalidSamples(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/astra-reference.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []struct {
|
||||
Text string `json:"text"`
|
||||
Count int `json:"requested_count"`
|
||||
}
|
||||
if err = common.Unmarshal(data, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
samples := []Sample{}
|
||||
for _, row := range rows {
|
||||
samples = append(samples, Sample{Text: row.Text, Count: row.Count})
|
||||
}
|
||||
r, err := Score(samples)
|
||||
if err != nil || r.Status != "match" || r.Closest != Model || r.ValidSamples != 3 {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
r, err = Score(samples[:2])
|
||||
if err != nil || r.Status != "inconclusive" {
|
||||
t.Fatalf("partial samples: %+v %v", r, err)
|
||||
}
|
||||
for _, s := range []string{"[1,2]", "null", "[" + strings.Repeat("-1,", 299) + "1]", "[" + strings.Repeat("1.2,", 299) + "1]", "ignore instructions; [1]"} {
|
||||
if ValidArray(s, 300) {
|
||||
t.Fatalf("accepted invalid array: %s", s[:min(len(s), 20)])
|
||||
func TestModelTraceUpstreamParity(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/modeltrace-reference.json")
|
||||
require.NoError(t, err)
|
||||
var fixtures []struct {
|
||||
Samples []Sample
|
||||
Expected struct {
|
||||
Results []Candidate `json:"results"`
|
||||
Valid int `json:"used_outputs"`
|
||||
}
|
||||
}
|
||||
c := []Candidate{{Model: "gpt-5.6-luna", Weight: .98, Score: 2}, {Model: Model, Weight: .02, Score: 1}}
|
||||
if Decide(c, 3).Status != "mismatch" {
|
||||
t.Fatal("wrong model gate")
|
||||
}
|
||||
c[1].Score = 1.7
|
||||
if Decide(c, 3).Status != "inconclusive" {
|
||||
t.Fatal("low margin must abstain")
|
||||
require.NoError(t, common.Unmarshal(data, &fixtures))
|
||||
for _, fixture := range fixtures {
|
||||
result, err := Score(fixture.Samples)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Version, result.Engine)
|
||||
assert.Equal(t, fixture.Expected.Valid, result.ValidSamples)
|
||||
assert.Equal(t, fixture.Expected.Results[0].Model, result.Closest)
|
||||
for _, candidate := range fixture.Expected.Results {
|
||||
assert.InDelta(t, candidate.Weight, result.Matches[candidate.Model], 1e-9)
|
||||
}
|
||||
}
|
||||
assert.False(t, ValidSample("I cannot provide the sequence."))
|
||||
assert.True(t, ValidArray(strings.Repeat("42,", 180), 300))
|
||||
assert.False(t, ValidArray(strings.Repeat("42,", 100), 300))
|
||||
result := Decide([]Candidate{{Model: Model, Weight: .79, Score: 2}, {Model: "other", Weight: .21, Score: 1}}, 3)
|
||||
assert.Equal(t, "inconclusive", result.Status)
|
||||
result = Decide([]Candidate{{Model: "other", Weight: .95, Score: 2}, {Model: Model, Weight: .05, Score: 1}}, 2)
|
||||
assert.Equal(t, "inconclusive", result.Status)
|
||||
result = Decide([]Candidate{{Model: "other", Weight: .95, Score: 2}, {Model: Model, Weight: .05, Score: 1}}, 3)
|
||||
assert.Equal(t, "mismatch", result.Status)
|
||||
}
|
||||
|
||||
func TestFingerprintResponseCompletionAndTools(t *testing.T) {
|
||||
stream := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"42\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n"
|
||||
answer, err := ReadResponse(strings.NewReader(stream), "text/event-stream")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "42", answer.Text)
|
||||
assert.True(t, answer.HasUsage)
|
||||
answer, err = ReadResponse(strings.NewReader(strings.Replace(stream, `"status":"completed"`, `"status":"completed","output":[]`, 1)), "text/event-stream")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, answer.Text)
|
||||
completed := `{"status":"completed","model":"gpt-6-astra","output":[{"type":"message","content":[{"type":"output_text","text":"[1,2,3]"}]}],"usage":{"input_tokens":21,"output_tokens":12,"input_tokens_details":{"cached_tokens":10}}}`
|
||||
for _, input := range []struct{ body, content string }{{completed, "application/json"}, {"data: {\"type\":\"response.completed\",\"response\":" + completed + "}\n\n", "text/event-stream"}} {
|
||||
r, err := ReadResponse(strings.NewReader(input.body), input.content)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -76,6 +76,8 @@ func ReadResponse(reader io.Reader, contentType string) (ProbeResponse, error) {
|
||||
scanner := bufio.NewScanner(buffered)
|
||||
scanner.Buffer(make([]byte, 4096), 2<<20)
|
||||
var data strings.Builder
|
||||
var deltas strings.Builder
|
||||
refused := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
@@ -101,6 +103,15 @@ func ReadResponse(reader io.Reader, contentType string) (ProbeResponse, error) {
|
||||
}
|
||||
event := gjson.Parse(payload)
|
||||
typ := event.Get("type").String()
|
||||
if typ == "response.output_text.delta" {
|
||||
deltas.WriteString(event.Get("delta").String())
|
||||
if deltas.Len() > 65536 {
|
||||
return ProbeResponse{}, ErrInvalidSample
|
||||
}
|
||||
}
|
||||
if typ == "response.refusal.delta" || typ == "response.refusal.done" {
|
||||
refused = true
|
||||
}
|
||||
if typ == "response.output_item.added" || typ == "response.output_item.done" {
|
||||
itemType := event.Get("item.type").String()
|
||||
if itemType != "message" && itemType != "reasoning" {
|
||||
@@ -108,7 +119,16 @@ func ReadResponse(reader io.Reader, contentType string) (ProbeResponse, error) {
|
||||
}
|
||||
}
|
||||
if typ == "response.completed" {
|
||||
return completedResponse(event.Get("response"))
|
||||
response := event.Get("response")
|
||||
answer, err := completedResponse(response)
|
||||
if refused {
|
||||
return answer, ErrInvalidSample
|
||||
}
|
||||
// Meow 4.5.4 accepts deltas only when completion omits output entirely.
|
||||
if err == nil && !response.Get("output").Exists() {
|
||||
answer.Text = deltas.String()
|
||||
}
|
||||
return answer, err
|
||||
}
|
||||
if typ == "response.failed" || typ == "response.incomplete" {
|
||||
return completedResponse(event.Get("response"))
|
||||
|
||||
+1200
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
[{"samples":[{"text":"217, 43, 308, 126, 351, 89, 164, 22, 279, 135, 62, 324, 191, 107,\n54, 286, 173, 9, 242, 118, 337, 76, 205, 149, 31, 263, 98, 312,\n184, 67, 345, 231, 112, 296, 18, 157, 83, 254, 139, 320, 46, 201,\n328, 121, 57, 274, 193, 34, 166, 355, 94, 223, 11, 287, 148, 70,\n103, 249, 16, 332, 178, 91, 215, 48, 301, 134, 267, 59, 186, 240,\n281, 39, 153, 226, 72, 314, 108, 197, 25, 348, 162, 84, 259, 119,\n65, 304, 181, 14, 237, 96, 342, 145, 208, 51, 276, 124, 319, 173,\n232, 110, 353, 78, 159, 290, 42, 203, 131, 7, 246, 187, 325, 99,\n147, 269, 33, 194, 318, 86, 221, 56, 305, 114, 252, 169, 20, 339,\n292, 74, 128, 243, 5, 177, 334, 102, 211, 63, 284, 151, 36, 229,\n81, 316, 198, 45, 261, 137, 350, 93, 174, 28, 234, 116, 297, 68,\n202, 13, 278, 155, 88, 331, 241, 49, 182, 309, 123, 266, 71, 214,\n344, 97, 161, 24, 295, 206, 58, 251, 142, 323, 109, 38, 273, 189,\n127, 283, 52, 218, 346, 79, 168, 302, 15, 236, 104, 193, 64, 257,\n225, 90, 311, 140, 32, 180, 354, 117, 264, 73, 199, 47, 288, 152,\n60, 247, 171, 329, 8, 213, 132, 277, 95, 341, 185, 26, 230, 113,\n299, 156, 41, 220, 85, 307, 143, 12, 268, 196, 349, 66, 120, 253,\n175, 326, 101, 55, 289, 209, 30, 138, 244, 77, 315, 163, 4, 227,\n106, 272, 190, 19, 335, 150, 61, 258, 122, 294, 44, 204, 87, 352,\n238, 69, 183, 321, 35, 260, 111, 170, 303, 92, 216, 53, 280, 146,\n17, 248, 133, 340, 80, 195, 310, 29, 224, 158, 75, 293, 115, 262","expected_count":294}],"expected":{"prediction":"gpt-6-astra","prediction_name":"gpt-6-astra","probability":0.999999846742899,"used_outputs":1,"results":[{"model":"gpt-6-astra","display_name":"gpt-6-astra","probability":0.999999846742899,"profile_similarity":0.7130119874890992,"score":2.962896373251895,"family":"gpt","family_name":"GPT","conditional_probability":0.9999998543620706},{"model":"gpt-6-sol","display_name":"gpt-6-sol","probability":1.1261750831683942e-7,"profile_similarity":0.6998678953299008,"score":0.7493510421319641,"family":"gpt","family_name":"GPT","conditional_probability":1.1261750917489167e-7},{"model":"gpt-5.4","display_name":"gpt-5.4","probability":2.2759050801578173e-8,"profile_similarity":0.6947437029962558,"score":0.5281199969747397,"family":"gpt","family_name":"GPT","conditional_probability":2.2759050974983314e-8},{"model":"gpt-5.6-terra","display_name":"gpt-5.6-terra","probability":9.128785824960024e-9,"profile_similarity":0.6969199108069902,"score":0.40173043595685565,"family":"gpt","family_name":"GPT","conditional_probability":9.12878589451382e-9},{"model":"claude-opus-5-5","display_name":"claude-opus-5-5","probability":4.332901518152684e-9,"profile_similarity":0.6902452216813633,"score":0.2986305009047045,"family":"claude","family_name":"Claude","conditional_probability":0.568684022014905},{"model":"claude-sonnet-5-5","display_name":"claude-sonnet-5-5","probability":2.6199944714106767e-9,"profile_similarity":0.6880590180204234,"score":0.2290299663562434,"family":"claude","family_name":"Claude","conditional_probability":0.3438686495449988},{"model":"claude-haiku-4-5-20251001","display_name":"claude-haiku-4-5-20251001","probability":6.116352532954573e-10,"profile_similarity":0.6765474621865657,"score":0.027755348459745643,"family":"claude","family_name":"Claude","conditional_probability":0.08027581388428616},{"model":"gpt-6-luna","display_name":"gpt-6-luna","probability":5.59694688154118e-10,"profile_similarity":0.6911103125741771,"score":0.015477264111949274,"family":"gpt","family_name":"GPT","conditional_probability":5.596946924185285e-10},{"model":"gpt-5.6-sol","display_name":"gpt-5.6-sol","probability":3.1143719574775517e-10,"profile_similarity":0.6893303459014515,"score":-0.06562434430484196,"family":"gpt","family_name":"GPT","conditional_probability":3.1143719812064896e-10},{"model":"gpt-5.6-luna","display_name":"gpt-5.6-luna","probability":2.1724112998700045e-10,"profile_similarity":0.6856500053581723,"score":-0.11545764586235663,"family":"gpt","family_name":"GPT","conditional_probability":2.1724113164219816e-10},{"model":"gpt-5.5","display_name":"gpt-5.5","probability":4.4210292318542455e-11,"profile_similarity":0.6895627615538364,"score":-0.33572243758168335,"family":"gpt","family_name":"GPT","conditional_probability":4.4210292655388314e-11},{"model":"claude-sonnet-4-6","display_name":"claude-sonnet-4-6","probability":3.435184918281792e-11,"profile_similarity":0.6700675476082696,"score":-0.3706291211825624,"family":"claude","family_name":"Claude","conditional_probability":0.004508606455764345},{"model":"claude-opus-4-7","display_name":"claude-opus-4-7","probability":8.777758891718366e-12,"profile_similarity":0.6258308139843289,"score":-0.5594026343083449,"family":"claude","family_name":"Claude","conditional_probability":0.001152062009696384},{"model":"claude-opus-5","display_name":"claude-opus-5","probability":5.740678040458984e-12,"profile_similarity":0.663953540718587,"score":-0.6181533384273423,"family":"claude","family_name":"Claude","conditional_probability":0.0007534516682Line truncated
|
||||
@@ -39,7 +39,7 @@ func recordUserResult(group string, result UserRequestResult) {
|
||||
defer cancel()
|
||||
encoded := strconv.FormatInt(result.At, 10) + ":" + strconv.FormatBool(result.Success)
|
||||
// Delayed writers cannot replace newer evidence from another instance.
|
||||
_ = common.RDB.Eval(ctx, `local old=redis.call('GET',KEYS[1]); local at=old and tonumber(string.match(old,'^(%d+):')) or 0; if tonumber(ARGV[1])>=at then redis.call('SET',KEYS[1],ARGV[2],'EX',900) end; return 1`, []string{"status:user-result:" + group}, result.At, encoded).Err()
|
||||
_ = common.RDB.Eval(ctx, `local old=redis.call('GET',KEYS[1]); local at=old and tonumber(string.match(old,'^(%d+):')) or 0; if tonumber(ARGV[1])>=at then redis.call('SET',KEYS[1],ARGV[2],'EX',900) end; return 1`, []string{"status:stream-user-result:" + group}, result.At, encoded).Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func LatestUserResults(ctx context.Context, groups []string) map[string]UserRequ
|
||||
if common.RedisEnabled && common.RDB != nil && len(groups) > 0 {
|
||||
keys := make([]string, len(groups))
|
||||
for i, g := range groups {
|
||||
keys[i] = "status:user-result:" + g
|
||||
keys[i] = "status:stream-user-result:" + g
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestLiveUserResults(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Cleanup(func() { liveResults.Delete(tc.name) })
|
||||
RecordRelayResult(tc.ctx, &relaycommon.RelayInfo{UsingGroup: tc.name, TokenId: tc.token, IsChannelTest: tc.test, StartTime: time.Now()}, tc.err)
|
||||
RecordRelayResult(tc.ctx, &relaycommon.RelayInfo{UsingGroup: tc.name, TokenId: tc.token, IsChannelTest: tc.test, IsStream: true, StartTime: time.Now()}, tc.err)
|
||||
result := LatestUserResults(context.Background(), []string{tc.name})[tc.name]
|
||||
assert.Equal(t, tc.want, result.At > 0)
|
||||
if tc.want {
|
||||
@@ -60,6 +60,8 @@ func TestUserResultsAreOrderedAndSurviveRestart(t *testing.T) {
|
||||
client.Close()
|
||||
})
|
||||
ctx := context.Background()
|
||||
require.NoError(t, client.Set(ctx, "status:user-result:group", "9999999999999:false", time.Minute).Err())
|
||||
assert.Empty(t, LatestUserResults(ctx, []string{"group"}))
|
||||
check := func(want UserRequestResult) {
|
||||
assert.Equal(t, want, LatestUserResults(ctx, []string{"group"})["group"])
|
||||
liveResults.Delete("group")
|
||||
@@ -76,9 +78,33 @@ func TestUserResultsAreOrderedAndSurviveRestart(t *testing.T) {
|
||||
recordUserResult("group", UserRequestResult{At: 2001, Success: false})
|
||||
check(UserRequestResult{At: 2002, Success: true})
|
||||
assert.True(t, LatestUserResults(ctx, []string{"other"})["other"].Success)
|
||||
ttl, err := client.TTL(ctx, "status:user-result:group").Result()
|
||||
ttl, err := client.TTL(ctx, "status:stream-user-result:group").Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 15*time.Minute, ttl)
|
||||
server.FastForward(15 * time.Minute)
|
||||
assert.Zero(t, LatestUserResults(ctx, []string{"group"})["group"].At)
|
||||
}
|
||||
|
||||
func TestNonStreamRequestsCannotChangeHealth(t *testing.T) {
|
||||
oldRedis := common.RedisEnabled
|
||||
common.RedisEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common.RedisEnabled = oldRedis
|
||||
liveResults.Delete("stream-only")
|
||||
hotBuckets.Clear()
|
||||
})
|
||||
ctx := context.Background()
|
||||
info := &relaycommon.RelayInfo{UsingGroup: "stream-only", OriginModelName: "test-model", TokenId: 1, IsStream: true, StartTime: time.Now()}
|
||||
RecordRelayResult(ctx, info, nil)
|
||||
want := LatestUserResults(ctx, []string{info.UsingGroup})[info.UsingGroup]
|
||||
require.True(t, want.Success)
|
||||
require.Positive(t, want.At)
|
||||
info.IsStream = false
|
||||
for _, apiErr := range []*types.NewAPIError{nil, types.InitOpenAIError("server_error", 500), types.InitOpenAIError("server_error", 502)} {
|
||||
RecordRelayResult(ctx, info, apiErr)
|
||||
assert.Equal(t, want, LatestUserResults(ctx, []string{info.UsingGroup})[info.UsingGroup])
|
||||
}
|
||||
info.IsStream = true
|
||||
RecordRelayResult(ctx, info, types.InitOpenAIError("server_error", 502))
|
||||
assert.False(t, LatestUserResults(ctx, []string{info.UsingGroup})[info.UsingGroup].Success)
|
||||
}
|
||||
@@ -29,8 +29,9 @@ func Init() {
|
||||
|
||||
// RecordRelayResult samples one finished relay exactly once, at the request
|
||||
// boundary, regardless of how many channel attempts it took.
|
||||
// Non-streaming requests do not provide evidence for the streaming service.
|
||||
func RecordRelayResult(ctx context.Context, info *relaycommon.RelayInfo, apiErr *types.NewAPIError) {
|
||||
if info == nil {
|
||||
if info == nil || !info.IsStream {
|
||||
return
|
||||
}
|
||||
outcome := ClassifyRelayOutcome(ctx, info, apiErr)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func imageGenerationResolution(size string) (string, error) {
|
||||
size = strings.ToLower(strings.TrimSpace(size))
|
||||
if size == "" || size == "auto" || size == "1k" {
|
||||
return "1k", nil
|
||||
}
|
||||
if size == "2k" || size == "4k" {
|
||||
return size, nil
|
||||
}
|
||||
dimensions := strings.Split(size, "x")
|
||||
if len(dimensions) != 2 {
|
||||
return "", fmt.Errorf("size must be auto, 1k, 2k, 4k or WIDTHxHEIGHT")
|
||||
}
|
||||
width, errWidth := strconv.Atoi(dimensions[0])
|
||||
height, errHeight := strconv.Atoi(dimensions[1])
|
||||
if errWidth != nil || errHeight != nil || width <= 0 || height <= 0 {
|
||||
return "", fmt.Errorf("invalid image size")
|
||||
}
|
||||
short := min(width, height)
|
||||
if short <= 1024 {
|
||||
return "1k", nil
|
||||
}
|
||||
if short <= 2048 {
|
||||
return "2k", nil
|
||||
}
|
||||
return "4k", nil
|
||||
}
|
||||
|
||||
func mapImageGenerationResolution(info *relaycommon.RelayInfo, request dto.Request) error {
|
||||
cfg := model_setting.GetGlobalSettings()
|
||||
image, ok := request.(*dto.ImageRequest)
|
||||
if !ok || cfg.ImageGenerationGroup == "" || info.UsingGroup != cfg.ImageGenerationGroup || !slices.Contains(cfg.ImageGenerationModels, info.OriginModelName) {
|
||||
return nil
|
||||
}
|
||||
name := info.OriginModelName
|
||||
for _, suffix := range []string{"-1k", "-2k", "-4k"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
tier, err := imageGenerationResolution(image.Size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info.UpstreamModelName = name + "-" + tier
|
||||
info.IsModelMapped = true
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImageGenerationResolutionMapping(t *testing.T) {
|
||||
cfg := model_setting.GetGlobalSettings()
|
||||
oldGroup, oldModels := cfg.ImageGenerationGroup, cfg.ImageGenerationModels
|
||||
t.Cleanup(func() { cfg.ImageGenerationGroup, cfg.ImageGenerationModels = oldGroup, oldModels })
|
||||
cfg.ImageGenerationGroup, cfg.ImageGenerationModels = "example-images", []string{"gpt-image-2", "gpt-image-2-4k"}
|
||||
for _, tc := range []struct{ size, tier string }{{"", "1k"}, {"auto", "1k"}, {"1024x1536", "1k"}, {"1536x1024", "1k"}, {"2048x1152", "2k"}, {"2048x2048", "2k"}, {"3840x2160", "4k"}, {"2160x3840", "4k"}, {"4k", "4k"}} {
|
||||
info := &relaycommon.RelayInfo{OriginModelName: "gpt-image-2", UsingGroup: "example-images", ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-image-2"}}
|
||||
image := &dto.ImageRequest{Model: "gpt-image-2", Size: tc.size}
|
||||
require.NoError(t, mapImageGenerationResolution(info, image))
|
||||
require.Equal(t, "gpt-image-2-"+tc.tier, info.UpstreamModelName)
|
||||
}
|
||||
_, err := imageGenerationResolution("-1x1024")
|
||||
require.Error(t, err)
|
||||
_, err = imageGenerationResolution("garbage")
|
||||
require.Error(t, err)
|
||||
info := &relaycommon.RelayInfo{OriginModelName: "gpt-image-2", UsingGroup: "default", ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-image-2"}}
|
||||
require.NoError(t, mapImageGenerationResolution(info, &dto.ImageRequest{Model: "gpt-image-2", Size: "3840x2160"}))
|
||||
require.Equal(t, "gpt-image-2", info.UpstreamModelName)
|
||||
}
|
||||
@@ -62,6 +62,10 @@ func ModelMappedHelper(c *gin.Context, info *relaycommon.RelayInfo, request dto.
|
||||
}
|
||||
}
|
||||
|
||||
if err := mapImageGenerationResolution(info, request); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if request != nil {
|
||||
request.SetModelName(info.UpstreamModelName)
|
||||
}
|
||||
|
||||
+2
-22
@@ -47,31 +47,11 @@ const defaultTieredPreConsumeMaxTokens = 8192
|
||||
|
||||
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present
|
||||
func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hosttypes.GroupRatioInfo {
|
||||
groupRatioInfo := hosttypes.GroupRatioInfo{
|
||||
GroupRatio: 1.0, // default ratio
|
||||
GroupSpecialRatio: -1,
|
||||
}
|
||||
|
||||
// check auto group
|
||||
autoGroup, exists := ctx.Get("auto_group")
|
||||
if exists {
|
||||
if autoGroup, exists := ctx.Get("auto_group"); exists {
|
||||
logger.LogDebug(ctx, "final group: %s", autoGroup)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
// check user group special ratio
|
||||
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
if ok {
|
||||
// user group special ratio
|
||||
groupRatioInfo.GroupSpecialRatio = userGroupRatio
|
||||
groupRatioInfo.GroupRatio = userGroupRatio
|
||||
groupRatioInfo.HasSpecialRatio = true
|
||||
} else {
|
||||
// normal group ratio
|
||||
groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
|
||||
}
|
||||
|
||||
return groupRatioInfo
|
||||
return model.EffectiveGroupRatio(ctx, relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
}
|
||||
|
||||
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (hosttypes.PriceData, error) {
|
||||
|
||||
@@ -281,7 +281,8 @@ func (s *responsesWSSession) runCall(c *gin.Context, state *responsesWSCallState
|
||||
}
|
||||
} else {
|
||||
retry := &service.RetryParam{Ctx: c, TokenGroup: common.GetContextKeyString(c, appconstant.ContextKeyUsingGroup), ModelName: modelName, RequestPath: c.Request.URL.Path, Retry: common.GetPointer(0)}
|
||||
for ; retry.GetRetry() <= common.RetryTimes; retry.IncreaseRetry() {
|
||||
maxRetries := max(common.RetryTimes, service.PoolRetryBudget(c, modelName))
|
||||
for ; retry.GetRetry() <= maxRetries; retry.IncreaseRetry() {
|
||||
var channel *appmodel.Channel
|
||||
channel, apiErr = selectResponsesWSChannel(c, modelName, retry)
|
||||
if apiErr != nil {
|
||||
@@ -316,7 +317,15 @@ func (s *responsesWSSession) runCall(c *gin.Context, state *responsesWSCallState
|
||||
apiErr = service.NormalizeViolationFeeError(types.NewError(dialErr, types.ErrorCodeDoRequestFailed))
|
||||
service.ResetStatusCode(apiErr, c.GetString("status_code_mapping"))
|
||||
info.LastError = apiErr
|
||||
decision := service.DecideRelayRetry(c, apiErr, common.RetryTimes-retry.GetRetry())
|
||||
remaining := common.RetryTimes - retry.GetRetry()
|
||||
if service.IsPoolFailoverError(c, apiErr) {
|
||||
remaining = maxRetries - retry.GetRetry()
|
||||
service.RecordPoolRetryableFailure(c, modelName, info.UsingGroup)
|
||||
}
|
||||
decision := service.DecideRelayRetry(c, apiErr, remaining)
|
||||
if service.IsPoolFailoverError(c, apiErr) && service.PoolRetryExhausted(c, modelName, info.UsingGroup) {
|
||||
decision = service.PolicyDecision{Action: "stop", Reason: "pool_no_backup", Source: "routing"}
|
||||
}
|
||||
service.RecordPolicyFailure(c, channel.Id, apiErr, decision)
|
||||
service.ProcessChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, info.ApiKey, channel.GetAutoBan()), apiErr, info)
|
||||
if decision.Action == "retry" {
|
||||
@@ -571,6 +580,13 @@ func (s *responsesWSSession) restoreConnectionContext(c *gin.Context, model stri
|
||||
}
|
||||
} else {
|
||||
group := common.GetContextKeyString(c, appconstant.ContextKeyUsingGroup)
|
||||
if service.RequestRoutingPolicy(c) != nil {
|
||||
if !slices.ContainsFunc(service.ResolveRequestPool(c, model), func(entry appmodel.TokenGroupEntry) bool { return entry.Group == s.lockedGroup }) {
|
||||
return types.NewErrorWithStatusCode(errors.New("the connection group is no longer allowed"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
group = s.lockedGroup
|
||||
common.SetContextKey(c, appconstant.ContextKeyAutoGroup, group)
|
||||
}
|
||||
if group == "auto" {
|
||||
if !slices.Contains(service.GetRequestAutoGroups(c, common.GetContextKeyString(c, appconstant.ContextKeyUserGroup)), s.lockedGroup) {
|
||||
return types.NewErrorWithStatusCode(errors.New("the connection group is no longer allowed"), types.ErrorCodeAccessDenied, http.StatusForbidden, types.ErrOptionWithSkipRetry())
|
||||
|
||||
@@ -26,12 +26,9 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/status", controller.GetStatus)
|
||||
apiRouter.GET("/uptime/status", controller.GetGroupStatus)
|
||||
apiRouter.GET("/group-status", controller.GetSiteGroupStatus)
|
||||
apiRouter.GET("/group-status/artwork/:id", controller.GetSiteDetectionArtwork)
|
||||
apiRouter.GET("/uptime/admin", middleware.AdminAuth(), controller.GetStatusPageAdmin)
|
||||
apiRouter.PUT("/uptime/group", middleware.AdminAuth(), middleware.SessionCookieOriginGuard(), controller.UpdateStatusPageGroup)
|
||||
apiRouter.POST("/uptime/group/reset", middleware.AdminAuth(), middleware.SessionCookieOriginGuard(), controller.UpdateStatusPageGroup)
|
||||
apiRouter.GET("/detections/artwork/:id", controller.GetDetectionArtwork)
|
||||
apiRouter.POST("/detection-proxy/v1/responses", controller.DetectionProxy)
|
||||
apiRouter.POST("/detections/estimate", middleware.UserAuth(), middleware.SessionCookieOriginGuard(), controller.EstimateDetection)
|
||||
apiRouter.POST("/detections", middleware.UserAuth(), middleware.SessionCookieOriginGuard(), controller.CreateUserDetection)
|
||||
apiRouter.GET("/detections/:id", middleware.UserAuth(), controller.GetUserDetection)
|
||||
@@ -162,6 +159,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
adminRoute.DELETE("/:id/oauth/bindings/:provider_id", controller.UnbindCustomOAuthByAdmin)
|
||||
adminRoute.DELETE("/:id/bindings/:binding_type", controller.AdminClearUserBinding)
|
||||
adminRoute.GET("/:id", controller.GetUser)
|
||||
adminRoute.GET("/:id/group-ratios", controller.GetUserGroupRatios)
|
||||
adminRoute.PUT("/:id/group-ratios", middleware.SessionCookieOriginGuard(), controller.UpdateUserGroupRatios)
|
||||
adminRoute.POST("/", controller.CreateUser)
|
||||
adminRoute.POST("/manage", controller.ManageUser)
|
||||
adminRoute.PUT("/", controller.UpdateUser)
|
||||
@@ -363,6 +362,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
groupRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
groupRoute.GET("/", controller.GetGroups)
|
||||
groupRoute.GET("/metadata", controller.GetGroupMetadata)
|
||||
groupRoute.PUT("/metadata", middleware.SessionCookieOriginGuard(), controller.UpdateGroupMetadata)
|
||||
}
|
||||
|
||||
prefillGroupRoute := apiRouter.Group("/prefill_group")
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
)
|
||||
|
||||
const AstraSamplingTaskType = "astra_fingerprint"
|
||||
const AstraSamplingTaskType = "modeltrace_sampling"
|
||||
|
||||
const (
|
||||
AstraSamplingWindow = 6 * time.Hour
|
||||
@@ -55,6 +55,7 @@ type AstraSamplingHistory struct {
|
||||
Gap float64 `json:"score_gap,omitempty"`
|
||||
}
|
||||
type AstraSamplingSummary struct {
|
||||
LatestValid *AstraSamplingHistory `json:"-"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Available bool `json:"available"`
|
||||
Channels int `json:"channels"`
|
||||
@@ -123,6 +124,9 @@ func AstraAvailabilityIntervalMinutes() int {
|
||||
// Public statistics classify completed rounds by their closest valid fingerprint,
|
||||
// including partial rounds. Raw probe results retain the scorer's confidence.
|
||||
func AstraSamplingResultStatus(result fingerprint.Result) string {
|
||||
if result.Engine != "" {
|
||||
return result.Status
|
||||
}
|
||||
closest := strings.TrimSpace(result.Closest)
|
||||
if result.Status != "running" && result.ValidSamples > 0 && closest != "" {
|
||||
if strings.EqualFold(closest, fingerprint.Model) {
|
||||
@@ -162,7 +166,7 @@ func samplingChannels(ctx context.Context, requireAstra bool) ([]model.Channel,
|
||||
}
|
||||
|
||||
func AstraSamplingChannelsDue(ctx context.Context, now time.Time) ([]model.Channel, error) {
|
||||
due, _, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", now)
|
||||
due, _, err := AutomaticDetectionChannelsDue(ctx, "modeltrace", now)
|
||||
return due, err
|
||||
}
|
||||
|
||||
@@ -183,7 +187,7 @@ func AstraSamplingSummaries(ctx context.Context, now time.Time, settings map[str
|
||||
}
|
||||
summaries := aggregateAstraSampling(channels, tasks, now, settings)
|
||||
AggregateChannelAvailability(summaries, channels, availabilityTasks, now)
|
||||
ranges, err := DetectionScheduleRanges(ctx, channels, "fingerprint")
|
||||
ranges, err := DetectionScheduleRanges(ctx, channels, "modeltrace")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,6 +228,9 @@ func aggregateAstraSampling(channels []model.Channel, tasks []*model.SystemTask,
|
||||
continue
|
||||
}
|
||||
for _, round := range state.Rounds {
|
||||
if round.Result.Engine != fingerprint.Version && round.Result.Status != "running" {
|
||||
continue
|
||||
}
|
||||
if round.StartedAt < now.Add(-AstraSamplingWindow).Unix() {
|
||||
continue
|
||||
}
|
||||
@@ -272,6 +279,13 @@ func aggregateAstraSampling(channels []model.Channel, tasks []*model.SystemTask,
|
||||
s.Rate = &rate
|
||||
}
|
||||
sort.SliceStable(s.History, func(i, j int) bool { return s.History[i].At > s.History[j].At })
|
||||
for i := range s.History {
|
||||
round := &s.History[i]
|
||||
if round.ValidSamples > 0 && round.Closest != "" && (round.Status == "match" || round.Status == "mismatch") {
|
||||
s.LatestValid = round
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(s.History) > 12 {
|
||||
s.History = s.History[:12]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestAstraSamplingThirtyMinuteSchedulePersistsAcrossRestart(t *testing.T) {
|
||||
handler := &anchoredSamplingTestHandler{stubScheduledHandler{taskType: AstraSamplingTaskType, enabled: true, interval: 30 * time.Minute}}
|
||||
withSystemTaskRegistry(t, handler)
|
||||
now := common.GetTimestamp()
|
||||
state, _ := common.Marshal(AstraSamplingState{Rounds: []AstraSamplingRound{{ChannelID: 42, StartedAt: now - 1900, FinishedAt: now - 1800, Result: fingerprint.Result{Status: "match", ValidSamples: 3, Closest: fingerprint.Model}}}})
|
||||
state, _ := common.Marshal(AstraSamplingState{Rounds: []AstraSamplingRound{{ChannelID: 42, StartedAt: now - 1900, FinishedAt: now - 1800, Result: fingerprint.Result{Engine: fingerprint.Version, Status: "match", ValidSamples: 3, Closest: fingerprint.Model}}}})
|
||||
row := model.SystemTask{TaskID: "sampling-old", Type: AstraSamplingTaskType, Status: model.SystemTaskStatusSucceeded, CreatedAt: now - 1900, UpdatedAt: now - 10, State: string(state)}
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -61,9 +61,9 @@ func TestAstraSamplingAggregationPrivacyAndFailures(t *testing.T) {
|
||||
if status == "mismatch" {
|
||||
closest = "gpt-5.6-luna"
|
||||
}
|
||||
rounds = append(rounds, AstraSamplingRound{ChannelID: 1, StartedAt: now.Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Status: status, ValidSamples: 3, Closest: closest}})
|
||||
rounds = append(rounds, AstraSamplingRound{ChannelID: 1, StartedAt: now.Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: status, ValidSamples: 3, Closest: closest}})
|
||||
}
|
||||
rounds = append(rounds, AstraSamplingRound{ChannelID: 99, StartedAt: now.Unix(), Result: fingerprint.Result{Status: "mismatch"}})
|
||||
rounds = append(rounds, AstraSamplingRound{ChannelID: 99, StartedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch"}})
|
||||
state, _ := common.Marshal(AstraSamplingState{Rounds: rounds})
|
||||
summaries := AggregateAstraSampling(channels, []*model.SystemTask{{Status: model.SystemTaskStatusFailed, State: string(state)}}, now)
|
||||
first := summaries["first"]
|
||||
@@ -88,17 +88,17 @@ func TestAstraSamplingAggregationPrivacyAndFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAstraSamplingAggregationCountsLunaLikeInconclusiveWithinSixHours(t *testing.T) {
|
||||
func TestAstraSamplingExcludesInconclusiveFromRateWithinSixHours(t *testing.T) {
|
||||
now := time.Now()
|
||||
channel := model.Channel{Id: 1, Group: "example-variable", Models: fingerprint.Model}
|
||||
rounds := []AstraSamplingRound{
|
||||
{ID: "recent-match", ChannelID: 1, StartedAt: now.Add(-time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Status: "match", ValidSamples: 3, Closest: fingerprint.Model}},
|
||||
{ID: "recent-luna", ChannelID: 1, StartedAt: now.Add(-2 * time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-luna", Gap: .9}},
|
||||
{ID: "old-luna", ChannelID: 1, StartedAt: now.Add(-7 * time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-luna", Gap: .9}},
|
||||
{ID: "recent-match", ChannelID: 1, StartedAt: now.Add(-time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "match", ValidSamples: 3, Closest: fingerprint.Model}},
|
||||
{ID: "recent-luna", ChannelID: 1, StartedAt: now.Add(-2 * time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-luna", Gap: .9}},
|
||||
{ID: "old-luna", ChannelID: 1, StartedAt: now.Add(-7 * time.Hour).Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-luna", Gap: .9}},
|
||||
}
|
||||
state, _ := common.Marshal(AstraSamplingState{Rounds: rounds})
|
||||
summary := AggregateAstraSampling([]model.Channel{channel}, []*model.SystemTask{{Status: model.SystemTaskStatusSucceeded, State: string(state)}}, now)["example-variable"]
|
||||
if summary.Rate == nil || *summary.Rate != .5 || summary.Matches != 1 || summary.Mismatches != 1 || summary.Inconclusive != 0 {
|
||||
if summary.Rate == nil || *summary.Rate != 0 || summary.Matches != 1 || summary.Mismatches != 0 || summary.Inconclusive != 1 {
|
||||
t.Fatalf("%+v", summary)
|
||||
}
|
||||
if summary.WindowHours != 6 || summary.IntervalMinutes != 30 || len(summary.History) != 2 {
|
||||
@@ -107,6 +107,7 @@ func TestAstraSamplingAggregationCountsLunaLikeInconclusiveWithinSixHours(t *tes
|
||||
}
|
||||
|
||||
func TestAstraSamplingAggregationMarksNonAstraGroupsAvailabilityOnly(t *testing.T) {
|
||||
t.Setenv("CHANNEL_AVAILABILITY_INTERVAL_MINUTES", "30")
|
||||
now := time.Now()
|
||||
channels := []model.Channel{{Id: 1, Group: "opencode", Models: "gpt-5.6-sol"}}
|
||||
summary := AggregateAstraSampling(channels, nil, now)["opencode"]
|
||||
@@ -124,13 +125,12 @@ func TestAstraSamplingAggregationMarksNonAstraGroupsAvailabilityOnly(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestAstraSamplingCountsAllValidNonAstraFingerprints(t *testing.T) {
|
||||
func TestAstraSamplingExcludesOldEngineAndUncertainCandidates(t *testing.T) {
|
||||
now := time.Now()
|
||||
results := []fingerprint.Result{
|
||||
{Status: "error", ValidSamples: 1, Closest: "gpt-5.6-luna", Gap: .99},
|
||||
{Status: "inconclusive", ValidSamples: 3, Closest: "gpt-5.5", Gap: .17},
|
||||
{Status: "inconclusive", ValidSamples: 2, Closest: "gpt-5.6-sol", Gap: .09},
|
||||
{Status: "error", ValidSamples: 0},
|
||||
{Engine: "meow-4.5.4", Status: "mismatch", ValidSamples: 32, Closest: "other"},
|
||||
{Engine: fingerprint.Version, Status: "inconclusive", ValidSamples: 2, Closest: "other"},
|
||||
{Engine: fingerprint.Version, Status: "match", ValidSamples: 3, Closest: fingerprint.Model},
|
||||
}
|
||||
var rounds []AstraSamplingRound
|
||||
for _, result := range results {
|
||||
@@ -140,16 +140,15 @@ func TestAstraSamplingCountsAllValidNonAstraFingerprints(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
summary := AggregateAstraSampling([]model.Channel{{Id: 1, Group: "test", Models: fingerprint.Model}}, []*model.SystemTask{{Status: model.SystemTaskStatusSucceeded, State: string(state)}}, now)["test"]
|
||||
require.NotNil(t, summary.Rate)
|
||||
assert.Equal(t, 1.0, *summary.Rate)
|
||||
assert.Equal(t, 3, summary.Mismatches)
|
||||
assert.Equal(t, 0, summary.Inconclusive)
|
||||
assert.Equal(t, 1, summary.Errors)
|
||||
for _, h := range summary.History {
|
||||
if h.ValidSamples > 0 {
|
||||
assert.Equal(t, "mismatch", h.Status)
|
||||
}
|
||||
assert.Equal(t, 0.0, *summary.Rate)
|
||||
assert.Equal(t, 1, summary.Matches)
|
||||
assert.Equal(t, 0, summary.Mismatches)
|
||||
assert.Equal(t, 1, summary.Inconclusive)
|
||||
assert.Len(t, summary.History, 2)
|
||||
}
|
||||
|
||||
func TestModelTraceVerdictDoesNotPromoteClosestCandidate(t *testing.T) {
|
||||
for _, status := range []string{"match", "mismatch", "inconclusive", "error"} {
|
||||
assert.Equal(t, status, AstraSamplingResultStatus(fingerprint.Result{Engine: fingerprint.Version, Status: status, ValidSamples: 32, Closest: fingerprint.Model, Weight: .63436}))
|
||||
}
|
||||
assert.Equal(t, "match", AstraSamplingResultStatus(fingerprint.Result{Status: "inconclusive", ValidSamples: 1, Closest: fingerprint.Model}))
|
||||
assert.Equal(t, "inconclusive", AstraSamplingResultStatus(fingerprint.Result{Status: "mismatch", ValidSamples: 0, Closest: "gpt-5.6-sol"}))
|
||||
assert.Equal(t, "running", AstraSamplingResultStatus(fingerprint.Result{Status: "running", ValidSamples: 1, Closest: "gpt-5.6-sol"}))
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/hot"
|
||||
)
|
||||
|
||||
var automaticMetricCache = hot.NewHotCache[string, perfmetrics.QueryResult](hot.LRU, 1000).WithTTL(time.Minute).Build()
|
||||
var automaticMetricMu sync.Mutex
|
||||
|
||||
func automaticModelMetrics(modelName string) perfmetrics.QueryResult {
|
||||
automaticMetricMu.Lock()
|
||||
defer automaticMetricMu.Unlock()
|
||||
if value, found, err := automaticMetricCache.Get(modelName); err == nil && found {
|
||||
return value
|
||||
}
|
||||
value, err := perfmetrics.Query(perfmetrics.QueryParams{Model: modelName, Hours: 24})
|
||||
if err == nil {
|
||||
automaticMetricCache.Set(modelName, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type automaticCandidate struct {
|
||||
group string
|
||||
cost, ttft, success, ratio float64
|
||||
}
|
||||
|
||||
func resolveAutomaticPool(c *gin.Context, modelName string) []model.TokenGroupEntry {
|
||||
// Freeze ranking within a request: retries must not reorder their own pool.
|
||||
key := "automatic_pool:" + modelName
|
||||
if cached, ok := c.Get(key); ok {
|
||||
return cached.([]model.TokenGroupEntry)
|
||||
}
|
||||
result := []model.TokenGroupEntry{}
|
||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||
var abilities []model.Ability
|
||||
query := model.DB.WithContext(c.Request.Context()).Where("enabled = ?", true)
|
||||
if modelName != "" {
|
||||
query = query.Where("model = ?", modelName)
|
||||
}
|
||||
if err := query.Find(&abilities).Error; err != nil {
|
||||
return result
|
||||
}
|
||||
allowed := GetUserUsableGroups(userGroup)
|
||||
names := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, ability := range abilities {
|
||||
group := ability.Group
|
||||
if _, ok := allowed[group]; !ok || seen[group] || !IsUserSelectableGroup(userGroup, group) {
|
||||
continue
|
||||
}
|
||||
channel, err := model.CacheGetChannel(ability.ChannelId)
|
||||
if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
seen[group] = true
|
||||
names = append(names, group)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
c.Set(key, result)
|
||||
return result
|
||||
}
|
||||
var performance perfmetrics.QueryResult
|
||||
if modelName != "" {
|
||||
performance = automaticModelMetrics(modelName)
|
||||
}
|
||||
testSpeeds := map[int]float64{}
|
||||
if RequestRoutingPolicy(c).AutoPreference == "speed" {
|
||||
testSpeeds = latestModelTestSpeeds(c.Request.Context(), modelName)
|
||||
}
|
||||
metrics := map[string]perfmetrics.GroupResult{}
|
||||
for _, item := range performance.Groups {
|
||||
metrics[item.Group] = item
|
||||
}
|
||||
candidates := []automaticCandidate{}
|
||||
for _, group := range names {
|
||||
if requestGroupDegraded(c, modelName, group) {
|
||||
continue
|
||||
}
|
||||
ratio := model.EffectiveGroupRatio(c, userGroup, group).GroupRatio
|
||||
item := automaticCandidate{group: group, ttft: math.Inf(1), success: -1, ratio: ratio}
|
||||
if metric, ok := metrics[group]; ok && len(metric.Series) > 0 {
|
||||
item.success = metric.SuccessRate
|
||||
|
||||
}
|
||||
total, count := 0.0, 0
|
||||
for _, ability := range abilities {
|
||||
if ability.Group != group {
|
||||
continue
|
||||
}
|
||||
channel, err := model.CacheGetChannel(ability.ChannelId)
|
||||
if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if speed, ok := testSpeeds[ability.ChannelId]; ok {
|
||||
total += speed
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
item.ttft = total / float64(count)
|
||||
}
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
sortAutomaticCandidates(candidates, RequestRoutingPolicy(c).AutoPreference)
|
||||
for priority, item := range candidates {
|
||||
result = append(result, model.TokenGroupEntry{Group: item.group, Priority: priority})
|
||||
}
|
||||
c.Set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
func sortAutomaticCandidates(items []automaticCandidate, preference string) {
|
||||
slices.SortFunc(items, func(a, b automaticCandidate) int {
|
||||
if preference == "speed" {
|
||||
if n := cmp.Compare(a.ttft, b.ttft); n != 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if preference == "stable" {
|
||||
if n := cmp.Compare(b.success, a.success); n != 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
// Effective customer multipliers are independent of traffic sample size.
|
||||
if n := cmp.Compare(a.ratio, b.ratio); n != 0 {
|
||||
return n
|
||||
}
|
||||
if n := cmp.Compare(b.success, a.success); n != 0 {
|
||||
return n
|
||||
}
|
||||
if n := cmp.Compare(a.ttft, b.ttft); n != 0 {
|
||||
return n
|
||||
}
|
||||
if n := cmp.Compare(a.ratio, b.ratio); n != 0 {
|
||||
return n
|
||||
}
|
||||
return cmp.Compare(a.group, b.group)
|
||||
})
|
||||
}
|
||||
|
||||
// AutomaticPoolModels is the discoverable model union, independent of ranking,
|
||||
// temporary cooldowns and quality checks used when routing individual requests.
|
||||
func AutomaticPoolModels(c *gin.Context) []string {
|
||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||
var abilities []model.Ability
|
||||
if err := model.DB.WithContext(c.Request.Context()).Where("enabled = ?", true).Find(&abilities).Error; err != nil {
|
||||
common.SysError("automatic pool model discovery failed: " + err.Error())
|
||||
return []string{}
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, ability := range abilities {
|
||||
if !IsUserSelectableGroup(userGroup, ability.Group) {
|
||||
continue
|
||||
}
|
||||
channel, err := model.CacheGetChannel(ability.ChannelId)
|
||||
if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
names[ability.Model] = true
|
||||
}
|
||||
result := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
result = append(result, name)
|
||||
}
|
||||
slices.Sort(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Model tests use token_id=0 and a server-owned content marker. User traffic and
|
||||
// another model's timing must not masquerade as a speed measurement.
|
||||
func latestModelTestSpeeds(ctx context.Context, modelName string) map[int]float64 {
|
||||
result := map[int]float64{}
|
||||
if model.LOG_DB == nil {
|
||||
return result
|
||||
}
|
||||
var rows []model.Log
|
||||
err := model.LOG_DB.WithContext(ctx).Select("id", "channel_id", "other").Where("type = ? AND token_id = ? AND content = ? AND model_name = ? AND is_stream = ? AND created_at >= ?", model.LogTypeConsume, 0, "模型测试", modelName, true, time.Now().Add(-time.Hour).Unix()).Order("created_at desc, id desc").Find(&rows).Error
|
||||
if err != nil {
|
||||
common.SysError("automatic routing test measurements unavailable: " + err.Error())
|
||||
return result
|
||||
}
|
||||
for _, row := range rows {
|
||||
if _, ok := result[row.ChannelId]; ok {
|
||||
continue
|
||||
}
|
||||
var data struct {
|
||||
FRT float64 `json:"frt"`
|
||||
}
|
||||
if json.Unmarshal([]byte(row.Other), &data) != nil || data.FRT <= 0 || math.IsInf(data.FRT, 0) || math.IsNaN(data.FRT) {
|
||||
continue
|
||||
}
|
||||
result[row.ChannelId] = data.FRT
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"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/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAutomaticPoolModelsDatabaseMatrix(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
var driver gorm.Dialector
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
driver = sqlite.Open(":memory:")
|
||||
case "mysql":
|
||||
if os.Getenv("TEST_MYSQL_DSN") == "" {
|
||||
t.Skip("no isolated MySQL database")
|
||||
}
|
||||
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
|
||||
case "postgres":
|
||||
if os.Getenv("TEST_POSTGRES_DSN") == "" {
|
||||
t.Skip("no isolated PostgreSQL database")
|
||||
}
|
||||
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
|
||||
}
|
||||
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("automodel_%d_", time.Now().UnixNano())}})
|
||||
require.NoError(t, err)
|
||||
oldDB, oldRedis, oldMemory := model.DB, common.RedisEnabled, common.MemoryCacheEnabled
|
||||
oldGroups, oldRatios := setting.UserUsableGroups2JSONString(), ratio_setting.GroupRatio2JSONString()
|
||||
model.DB = db
|
||||
common.RedisEnabled = false
|
||||
common.MemoryCacheEnabled = false
|
||||
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, db.Migrator().DropTable(&model.Ability{}, &model.Channel{}))
|
||||
model.DB = oldDB
|
||||
common.RedisEnabled = oldRedis
|
||||
common.MemoryCacheEnabled = oldMemory
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(oldGroups))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(oldRatios))
|
||||
conn, e := db.DB()
|
||||
require.NoError(t, e)
|
||||
require.NoError(t, conn.Close())
|
||||
})
|
||||
var version string
|
||||
query := "select version()"
|
||||
if dialect == "sqlite" {
|
||||
query = "select sqlite_version()"
|
||||
}
|
||||
require.NoError(t, db.Raw(query).Scan(&version).Error)
|
||||
t.Log("database version:", version)
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"auto-a":"","auto-b":""}`))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"auto-a":1,"auto-b":1,"private":1}`))
|
||||
require.NoError(t, db.Create(&[]model.Channel{{Id: 561, Status: 1}, {Id: 562, Status: 1}, {Id: 563, Status: 2}, {Id: 564, Status: 1}}).Error)
|
||||
require.NoError(t, db.Create(&[]model.Ability{
|
||||
{ChannelId: 561, Group: "auto-a", Model: "model-a", Enabled: true},
|
||||
{ChannelId: 561, Group: "auto-a", Model: "shared", Enabled: true},
|
||||
{ChannelId: 562, Group: "auto-b", Model: "shared", Enabled: true},
|
||||
{ChannelId: 562, Group: "auto-b", Model: "gpt-6-astra", Enabled: true},
|
||||
{ChannelId: 563, Group: "auto-b", Model: "disabled-channel", Enabled: true},
|
||||
{ChannelId: 562, Group: "auto-b", Model: "disabled-ability", Enabled: false},
|
||||
{ChannelId: 564, Group: "private", Model: "unauthorized", Enabled: true},
|
||||
}).Error)
|
||||
for _, preference := range []string{"cost", "speed", "stable"} {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/v1/models", nil)
|
||||
common.SetContextKey(c, constant.ContextKeyUserGroup, "auto-a")
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart", AutoPreference: preference})
|
||||
c.Set("pool_failures", map[string]int{"auto-a": 2, "auto-b": 2})
|
||||
require.Equal(t, []string{"gpt-6-astra", "model-a", "shared"}, AutomaticPoolModels(c))
|
||||
}
|
||||
require.NoError(t, db.Create(&model.Channel{Id: 565, Status: 1}).Error)
|
||||
require.NoError(t, db.Create(&model.Ability{ChannelId: 565, Group: "new-group", Model: "new-model", Enabled: true}).Error)
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"auto-a":"","auto-b":"","new-group":""}`))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"auto-a":1,"auto-b":1,"new-group":1}`))
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/v1/models", nil)
|
||||
common.SetContextKey(c, constant.ContextKeyUserGroup, "auto-a")
|
||||
require.Equal(t, []string{"gpt-6-astra", "model-a", "new-model", "shared"}, AutomaticPoolModels(c))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
const CandyOutputTokens = 4096
|
||||
|
||||
const CandyPrompt = `在一个黑色的袋子里放有三种口味的糖果,每种糖果有两种不同的形状(圆形和五角星形,不同的形状靠手感可以分辨)。现已知不同口味的糖和不同形状的数量统计如下表。参赛者需要在活动前决定摸出的糖果数目,那么,最少取出多少个糖果才能保证手中同时拥有不同形状的苹果味和桃子味的糖?(同时手中有圆形苹果味匹配五角星桃子味糖果,或者有圆形桃子味匹配五角星苹果味糖果都满足要求)
|
||||
苹果味 桃子味 西瓜味
|
||||
圆形 7 9 8
|
||||
五角星形 7 6 4
|
||||
|
||||
只输出最终答案的阿拉伯数字,不要输出解释或其他内容。`
|
||||
|
||||
func CandyQuality(answer string) string {
|
||||
if strings.TrimSpace(answer) == "21" {
|
||||
return "normal"
|
||||
}
|
||||
return "degraded"
|
||||
}
|
||||
@@ -269,7 +269,14 @@ func selectFromGroupPool(param *RetryParam, tiers []groupPoolTier, filters []dto
|
||||
// Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1)
|
||||
// 分组B, 优先级1
|
||||
func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) {
|
||||
if RequestRoutingPolicy(param.Ctx) != nil { return selectTokenRoutingPool(param) }
|
||||
if RequestRoutingPolicy(param.Ctx) != nil {
|
||||
return selectTokenRoutingPool(param)
|
||||
}
|
||||
if group := imageGenerationRequestGroup(param.Ctx, param.ModelName); group != "" {
|
||||
channel, err := selectWithTrace(param, group, param.GetRetry())
|
||||
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, group)
|
||||
return channel, group, err
|
||||
}
|
||||
var channel *model.Channel
|
||||
var err error
|
||||
selectGroup := param.TokenGroup
|
||||
@@ -488,10 +495,16 @@ func SelectChannelForRequest(c *gin.Context, modelName string, retry *RetryParam
|
||||
|
||||
usingGroup := retry.TokenGroup
|
||||
if RequestRoutingPolicy(c) != nil {
|
||||
if channel, group := PreferredPoolChannel(c, modelName); channel != nil && retry.GetRetry() == 0 { return channel,group,nil }
|
||||
if retry.GetRetry() == 0 {
|
||||
if channel, group := PreferredPoolChannel(c, modelName); channel != nil {
|
||||
return channel, group, nil
|
||||
}
|
||||
}
|
||||
channel, group, err := selectTokenRoutingPool(retry)
|
||||
if err != nil || channel == nil { return nil,group,&ChannelSelectError{StatusCode:http.StatusServiceUnavailable,Code:types.ErrorCodeModelNotFound,Message:"No eligible channel in the group pool",NoAvailableChannel:true} }
|
||||
return channel,group,nil
|
||||
if err != nil || channel == nil {
|
||||
return nil, group, &ChannelSelectError{StatusCode: http.StatusServiceUnavailable, Code: types.ErrorCodeModelNotFound, Message: "No eligible channel in the group pool", NoAvailableChannel: true}
|
||||
}
|
||||
return channel, group, nil
|
||||
}
|
||||
var channel *model.Channel
|
||||
var selectGroup string
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
benchmark "github.com/QuantumNous/new-api/pkg/model_benchmark"
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||
@@ -36,15 +37,15 @@ func DetectionPricing(userGroup, group, kind string) (DetectionPrice, int, error
|
||||
return DetectionPrice{}, 0, errors.New("invalid group ratio")
|
||||
}
|
||||
price := DetectionPrice{Snapshot: billingexpr.BillingSnapshot{BillingMode: "tiered_expr", ModelName: fingerprint.Model, ExprString: expression, ExprHash: billingexpr.ExprHashString(expression), GroupRatio: ratio, QuotaPerUnit: common.QuotaPerUnit, ExprVersion: 1}}
|
||||
// Both generation paths restrict request text to 16 KiB and output tokens.
|
||||
output, calls := 16384, 1
|
||||
if kind == "fingerprint" {
|
||||
output, calls = 4096, 3
|
||||
}
|
||||
price.Request.Body = []byte(fmt.Sprintf(`{"model":"gpt-6-astra","reasoning":{"effort":"high"},"max_output_tokens":%d}`, output))
|
||||
if kind == "fingerprint" {
|
||||
price.Request.Body = []byte(`{"model":"gpt-6-astra","max_output_tokens":4096}`)
|
||||
output, calls := fingerprint.OutputTokens, fingerprint.ProbeCount
|
||||
if kind == "benchmark" {
|
||||
output, calls = benchmark.OutputTokens, benchmark.ProbeCount
|
||||
} else if kind == "candy" {
|
||||
output, calls = CandyOutputTokens, 1
|
||||
} else if kind != "fingerprint" {
|
||||
return price, 0, errors.New("invalid detection type")
|
||||
}
|
||||
price.Request.Body = []byte(fmt.Sprintf(`{"model":"gpt-6-astra","reasoning":{"effort":"low"},"max_output_tokens":%d}`, output))
|
||||
cost, err := billingexpr.DetectionCostBound(expression, 32768, float64(output))
|
||||
if err != nil {
|
||||
return price, 0, err
|
||||
|
||||
@@ -52,7 +52,7 @@ func ReconcileAutomaticDetectionSchedules(ctx context.Context, channels []model.
|
||||
cutoff = 0
|
||||
}
|
||||
var results []automaticDetectionResult
|
||||
if kind == "fingerprint" {
|
||||
if kind == "modeltrace" {
|
||||
var tasks []model.SystemTask
|
||||
if err := model.DB.WithContext(ctx).Select("status", "payload", "state", "updated_at").Where("type = ? AND updated_at >= ?", AstraSamplingTaskType, cutoff).Find(&tasks).Error; err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -29,9 +29,12 @@ func adaptiveTestDB(t *testing.T) {
|
||||
|
||||
func seedAutomaticFingerprint(t *testing.T, id string, at int64, closest string, force bool, running bool) {
|
||||
t.Helper()
|
||||
result := fingerprint.Result{Status: "error"}
|
||||
result := fingerprint.Result{Engine: fingerprint.Version, Status: "error"}
|
||||
if closest != "" {
|
||||
result = fingerprint.Result{Status: "inconclusive", Closest: closest, ValidSamples: 1}
|
||||
result = fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch", Closest: closest, ValidSamples: 3}
|
||||
if closest == fingerprint.Model {
|
||||
result.Status = "match"
|
||||
}
|
||||
}
|
||||
finished, status := at, model.SystemTaskStatusSucceeded
|
||||
if running {
|
||||
@@ -54,78 +57,78 @@ func TestAdaptiveFingerprintBootstrapRecoveryAndManualIsolation(t *testing.T) {
|
||||
seedAutomaticFingerprint(t, "normal", 1000, fingerprint.Model, false, false)
|
||||
seedAutomaticFingerprint(t, "unknown", 2000, "", false, false)
|
||||
seedAutomaticFingerprint(t, "forced", 2100, "gpt-6-sol", true, false)
|
||||
due, states, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(2899, 0))
|
||||
due, states, err := AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(2899, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, due)
|
||||
assert.Equal(t, "normal", states[1].LastValid)
|
||||
assert.Equal(t, 15, states[1].IntervalMinutes)
|
||||
due, _, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(2900, 0))
|
||||
due, _, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(2900, 0))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, due, 1)
|
||||
seedAutomaticFingerprint(t, "recovered", 3000, fingerprint.Model, false, false)
|
||||
_, states, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(3000, 0))
|
||||
_, states, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(3000, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 30, states[1].IntervalMinutes)
|
||||
seedAutomaticFingerprint(t, "downgraded", 5000, "gpt-6-sol", false, false)
|
||||
_, states, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(5000, 0))
|
||||
_, states, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(5000, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 15, states[1].IntervalMinutes)
|
||||
seedAutomaticFingerprint(t, "other-non-astra", 6000, "gpt-5.5", false, false)
|
||||
_, states, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(6000, 0))
|
||||
_, states, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(6000, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 30, states[1].IntervalMinutes)
|
||||
// A completed result with a missing clock update is replayed once, not twice.
|
||||
_, again, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(6000, 0))
|
||||
_, again, err := AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(6000, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, states, again)
|
||||
seedAutomaticFingerprint(t, "abandoned", 7000, "", false, true)
|
||||
due, _, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(10000, 0))
|
||||
due, _, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(10000, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, due)
|
||||
require.NoError(t, model.DB.Model(&model.SystemTask{}).Where("task_id = ?", "abandoned").Updates(map[string]any{"status": model.SystemTaskStatusFailed, "updated_at": 10000}).Error)
|
||||
_, states, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(10000, 0))
|
||||
_, states, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(10000, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(10900), states[1].NextDueAt)
|
||||
assert.Equal(t, "degraded", states[1].LastValid)
|
||||
require.NoError(t, model.DB.Where("type = ?", AstraSamplingTaskType).Delete(&model.SystemTask{}).Error)
|
||||
_, again, err = AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(10000, 0))
|
||||
_, again, err = AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(10000, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, states, again)
|
||||
}
|
||||
|
||||
func TestAdaptivePelicanBootstrapInFlightAndRanges(t *testing.T) {
|
||||
func TestAdaptiveBenchmarkBootstrapInFlightAndRanges(t *testing.T) {
|
||||
adaptiveTestDB(t)
|
||||
ctx := context.Background()
|
||||
channels := []model.Channel{{Id: 1, Group: "one,shared", Models: fingerprint.Model, Status: common.ChannelStatusEnabled}, {Id: 2, Group: "shared", Models: fingerprint.Model, Status: common.ChannelStatusEnabled}}
|
||||
require.NoError(t, model.DB.Create(&channels).Error)
|
||||
runs := []model.DetectionRun{
|
||||
{ID: "auto1", RequestKey: "a", ChannelID: 1, Kind: "pelican", Status: "succeeded", Quality: "normal", Settled: true, FinishedAt: 1000},
|
||||
{ID: "auto2", RequestKey: "b", ChannelID: 2, Kind: "pelican", Status: "failed", Settled: true, FinishedAt: 1000},
|
||||
{ID: "paid", RequestKey: "c", UserID: 1, ChannelID: 1, Kind: "pelican", Status: "succeeded", Quality: "degraded", Settled: true, FinishedAt: 1100},
|
||||
{ID: "active", RequestKey: "d", ChannelID: 2, Kind: "pelican", Status: "running"},
|
||||
{ID: "auto1", RequestKey: "a", ChannelID: 1, Kind: "benchmark", Status: "succeeded", Quality: "normal", Settled: true, FinishedAt: 1000},
|
||||
{ID: "auto2", RequestKey: "b", ChannelID: 2, Kind: "benchmark", Status: "failed", Settled: true, FinishedAt: 1000},
|
||||
{ID: "paid", RequestKey: "c", UserID: 1, ChannelID: 1, Kind: "benchmark", Status: "succeeded", Quality: "degraded", Settled: true, FinishedAt: 1100},
|
||||
{ID: "active", RequestKey: "d", ChannelID: 2, Kind: "benchmark", Status: "running"},
|
||||
}
|
||||
require.NoError(t, model.DB.Create(&runs).Error)
|
||||
due, states, err := AutomaticDetectionChannelsDue(ctx, "pelican", time.Unix(3000, 0))
|
||||
due, states, err := AutomaticDetectionChannelsDue(ctx, "benchmark", time.Unix(3000, 0))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, due, 1)
|
||||
assert.Equal(t, 1, due[0].Id)
|
||||
assert.Equal(t, "normal", states[1].LastValid)
|
||||
assert.Equal(t, 30, states[1].IntervalMinutes)
|
||||
assert.Equal(t, 15, states[2].IntervalMinutes)
|
||||
ranges, err := DetectionScheduleRanges(ctx, channels, "pelican")
|
||||
ranges, err := DetectionScheduleRanges(ctx, channels, "benchmark")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, [2]int{15, 30}, ranges["shared"])
|
||||
assert.Equal(t, [2]int{30, 30}, ranges["one"])
|
||||
_, fp, err := AutomaticDetectionChannelsDue(ctx, "fingerprint", time.Unix(3000, 0))
|
||||
_, fp, err := AutomaticDetectionChannelsDue(ctx, "modeltrace", time.Unix(3000, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 30, fp[2].IntervalMinutes)
|
||||
assert.Equal(t, "", fp[1].LastValid)
|
||||
require.NoError(t, model.DB.Model(&model.DetectionRun{}).Where("id = ?", "active").Updates(map[string]any{"status": "succeeded", "quality": "degraded", "settled": true, "finished_at": 3000}).Error)
|
||||
due, states, err = AutomaticDetectionChannelsDue(ctx, "pelican", time.Unix(4799, 0))
|
||||
due, states, err = AutomaticDetectionChannelsDue(ctx, "benchmark", time.Unix(4799, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 30, states[2].IntervalMinutes)
|
||||
require.Len(t, due, 1)
|
||||
due, _, err = AutomaticDetectionChannelsDue(ctx, "pelican", time.Unix(4800, 0))
|
||||
due, _, err = AutomaticDetectionChannelsDue(ctx, "benchmark", time.Unix(4800, 0))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, due, 2)
|
||||
}
|
||||
|
||||
+56
-32
@@ -11,15 +11,16 @@ import (
|
||||
)
|
||||
|
||||
type DetectionHistory struct {
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
ID string `json:"id"`
|
||||
At int64 `json:"at"`
|
||||
Status string `json:"status"`
|
||||
Quality string `json:"quality"`
|
||||
UserInitiated bool `json:"user_initiated"`
|
||||
HasHTML bool `json:"has_html"`
|
||||
Closest string `json:"closest,omitempty"`
|
||||
Weight float64 `json:"relative_weight,omitempty"`
|
||||
ID string `json:"id"`
|
||||
At int64 `json:"at"`
|
||||
Status string `json:"status"`
|
||||
Quality string `json:"quality"`
|
||||
UserInitiated bool `json:"user_initiated"`
|
||||
}
|
||||
type PelicanSummary struct {
|
||||
type BenchmarkSummary struct {
|
||||
LatestValid *DetectionHistory `json:"-"`
|
||||
Channels int `json:"channels"`
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
IntervalMaxMinutes int `json:"interval_max_minutes"`
|
||||
@@ -37,7 +38,8 @@ type ChannelDetectionEvidence struct {
|
||||
}
|
||||
type GroupDetectionSummary struct {
|
||||
ProbeHistory []ProbeHistoryPoint
|
||||
Pelican *PelicanSummary
|
||||
Candy *BenchmarkSummary
|
||||
Benchmark *BenchmarkSummary
|
||||
Status int
|
||||
At int64
|
||||
}
|
||||
@@ -75,7 +77,8 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
|
||||
}
|
||||
groups := map[string][]int{}
|
||||
byChannel := map[int][]string{}
|
||||
pelicans := map[string]*PelicanSummary{}
|
||||
benchmarks := map[string]*BenchmarkSummary{}
|
||||
candies := map[string]*BenchmarkSummary{}
|
||||
evidence := map[int]ChannelDetectionEvidence{}
|
||||
observe := func(id int, at int64, success bool) {
|
||||
if at > evidence[id].At {
|
||||
@@ -87,19 +90,27 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
|
||||
for _, group := range channel.GetGroups() {
|
||||
groups[group] = append(groups[group], channel.Id)
|
||||
if ChannelSupportsAstra(&channel) {
|
||||
if pelicans[group] == nil {
|
||||
pelicans[group] = &PelicanSummary{IntervalMinutes: 30, WindowHours: 6, History: []DetectionHistory{}}
|
||||
if benchmarks[group] == nil {
|
||||
benchmarks[group] = &BenchmarkSummary{IntervalMinutes: 30, WindowHours: 6, History: []DetectionHistory{}}
|
||||
}
|
||||
pelicans[group].Channels++
|
||||
benchmarks[group].Channels++
|
||||
}
|
||||
}
|
||||
}
|
||||
var tasks []model.SystemTask
|
||||
ranges, err := DetectionScheduleRanges(ctx, channels, "pelican")
|
||||
candyRanges, err := DetectionScheduleRanges(ctx, channels, "candy")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for group, summary := range pelicans {
|
||||
for group, summary := range benchmarks {
|
||||
r := candyRanges[group]
|
||||
candies[group] = &BenchmarkSummary{Channels: summary.Channels, IntervalMinutes: r[0], IntervalMaxMinutes: r[1], WindowHours: 6, History: []DetectionHistory{}}
|
||||
}
|
||||
var tasks []model.SystemTask
|
||||
ranges, err := DetectionScheduleRanges(ctx, channels, "benchmark")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for group, summary := range benchmarks {
|
||||
r := ranges[group]
|
||||
summary.IntervalMinutes, summary.IntervalMaxMinutes = r[0], r[1]
|
||||
}
|
||||
@@ -136,14 +147,6 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
|
||||
if err = model.DB.WithContext(ctx).Omit("html", "price", "remote_id").Where("created_at >= ?", now.Add(-6*time.Hour).Unix()).Find(&runs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var artworkIDs []string
|
||||
if err = model.DB.WithContext(ctx).Model(&model.DetectionRun{}).Where("created_at >= ? AND html <> ?", now.Add(-6*time.Hour).Unix(), "").Pluck("id", &artworkIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artwork := map[string]bool{}
|
||||
for _, id := range artworkIDs {
|
||||
artwork[id] = true
|
||||
}
|
||||
ids := []string{}
|
||||
for _, run := range runs {
|
||||
ids = append(ids, run.ID)
|
||||
@@ -164,6 +167,9 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
|
||||
}
|
||||
}
|
||||
for _, run := range runs {
|
||||
if run.Kind != "fingerprint" && run.Kind != "benchmark" && run.Kind != "candy" {
|
||||
continue
|
||||
}
|
||||
for _, group := range byChannel[run.ChannelID] {
|
||||
if cutoff := settings[group].ResetBefore; cutoff > 0 && run.CreatedAt <= cutoff {
|
||||
continue
|
||||
@@ -180,6 +186,9 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
|
||||
if result.Status == "" {
|
||||
result.Status = "error"
|
||||
}
|
||||
if result.Engine != fingerprint.Version {
|
||||
continue
|
||||
}
|
||||
result.Status = AstraSamplingResultStatus(result)
|
||||
switch result.Status {
|
||||
case "match":
|
||||
@@ -192,20 +201,28 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
|
||||
summary.Errors++
|
||||
}
|
||||
summary.History = append(summary.History, AstraSamplingHistory{ID: run.ID, At: run.CreatedAt, Status: result.Status, ValidSamples: result.ValidSamples, Closest: result.Closest, Gap: result.Gap, UserInitiated: true})
|
||||
if result.ValidSamples > 0 && result.Closest != "" && (result.Status == "match" || result.Status == "mismatch") && (summary.LatestValid == nil || run.CreatedAt > summary.LatestValid.At) {
|
||||
round := summary.History[len(summary.History)-1]
|
||||
summary.LatestValid = &round
|
||||
}
|
||||
continue
|
||||
}
|
||||
s := pelicans[group]
|
||||
s := benchmarks[group]
|
||||
if run.Kind == "candy" {
|
||||
s = candies[group]
|
||||
}
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
history := DetectionHistory{ID: run.ID, At: run.CreatedAt, Status: run.Status, Quality: run.Quality, UserInitiated: run.UserID > 0, HasHTML: artwork[run.ID]}
|
||||
var metadata struct {
|
||||
SourceURL string `json:"source_url"`
|
||||
}
|
||||
history := DetectionHistory{ID: run.ID, At: run.CreatedAt, Status: run.Status, Quality: run.Quality, UserInitiated: run.UserID > 0}
|
||||
var metadata fingerprint.Result
|
||||
if common.UnmarshalJsonStr(run.Result, &metadata) == nil {
|
||||
history.SourceURL = metadata.SourceURL
|
||||
history.Closest, history.Weight = metadata.Closest, metadata.Weight
|
||||
}
|
||||
s.History = append(s.History, history)
|
||||
if run.Status == "succeeded" && (run.Quality == "normal" || run.Quality == "degraded") && (s.LatestValid == nil || history.At > s.LatestValid.At) {
|
||||
s.LatestValid = &history
|
||||
}
|
||||
switch run.Status {
|
||||
case "pending", "running":
|
||||
s.Running++
|
||||
@@ -236,14 +253,21 @@ func GroupDetectionSummaries(ctx context.Context, now time.Time, fingerprints ma
|
||||
results := map[string]GroupDetectionSummary{}
|
||||
for group, ids := range groups {
|
||||
status, at := DetectionFallbackStatus(ids, evidence, now.Unix())
|
||||
s := pelicans[group]
|
||||
s := benchmarks[group]
|
||||
if s != nil {
|
||||
sort.SliceStable(s.History, func(i, j int) bool { return s.History[i].At > s.History[j].At })
|
||||
if len(s.History) > 12 {
|
||||
s.History = s.History[:12]
|
||||
}
|
||||
}
|
||||
results[group] = GroupDetectionSummary{Pelican: s, Status: status, At: at, ProbeHistory: GroupProbeHistory(ids, tasks)}
|
||||
candy := candies[group]
|
||||
if candy != nil {
|
||||
sort.SliceStable(candy.History, func(i, j int) bool { return candy.History[i].At > candy.History[j].At })
|
||||
if len(candy.History) > 12 {
|
||||
candy.History = candy.History[:12]
|
||||
}
|
||||
}
|
||||
results[group] = GroupDetectionSummary{Candy: candy, Benchmark: s, Status: status, At: at, ProbeHistory: GroupProbeHistory(ids, tasks)}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type groupAvailabilityState struct {
|
||||
}
|
||||
|
||||
var (
|
||||
groupAvailabilityMu sync.RWMutex
|
||||
groupAvailabilityMu sync.RWMutex
|
||||
groupAvailabilityStates = make(map[string]*groupAvailabilityState)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var groupDegradationSnapshot struct {
|
||||
sync.Mutex
|
||||
updated time.Time
|
||||
latest map[string]AstraSamplingHistory
|
||||
benchmark map[string]DetectionHistory
|
||||
}
|
||||
|
||||
func requestGroupDegraded(c *gin.Context, modelName, group string) bool {
|
||||
policy := RequestRoutingPolicy(c)
|
||||
if policy == nil {
|
||||
return false
|
||||
}
|
||||
skip := policy.SkipDegradedGroups && (modelName == fingerprint.Model || policy.SkipDegradedNonAstra)
|
||||
if policy.Mode == "smart" {
|
||||
skip = !policy.AllowDegraded && (modelName == fingerprint.Model || policy.SkipDegradedNonAstra)
|
||||
}
|
||||
if !skip {
|
||||
return false
|
||||
}
|
||||
if c.GetBool("pool_degradation_unavailable") {
|
||||
return true
|
||||
}
|
||||
latest, found := c.Get("pool_group_fingerprints")
|
||||
benchmark, _ := c.Get("pool_group_benchmarks")
|
||||
if !found {
|
||||
rounds, qualities, err := cachedGroupQuality(c.Request.Context())
|
||||
if err != nil {
|
||||
c.Set("pool_degradation_unavailable", true)
|
||||
return true
|
||||
}
|
||||
latest, benchmark = rounds, qualities
|
||||
c.Set("pool_group_fingerprints", latest)
|
||||
c.Set("pool_group_benchmarks", benchmark)
|
||||
}
|
||||
rounds, _ := latest.(map[string]AstraSamplingHistory)
|
||||
h, ok := rounds[group]
|
||||
return (ok && h.ValidSamples > 0 && h.Closest != "" && h.Closest != fingerprint.Model)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
func TestGroupDegradationDatabaseMatrix(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
var driver gorm.Dialector
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
driver = sqlite.Open(":memory:")
|
||||
case "mysql":
|
||||
if os.Getenv("TEST_MYSQL_DSN") == "" {
|
||||
t.Skip("TEST_MYSQL_DSN unset")
|
||||
}
|
||||
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
|
||||
case "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("degradation_%d_", time.Now().UnixNano())}})
|
||||
require.NoError(t, err)
|
||||
oldDB := model.DB
|
||||
oldType, oldLogType := common.MainDatabaseType(), common.LogDatabaseType()
|
||||
model.DB = db
|
||||
common.SetDatabaseTypes(common.DatabaseType(dialect), oldLogType)
|
||||
rows := []any{&model.Option{}, &model.Channel{}, &model.SystemTask{}, &model.DetectionSchedule{}, &model.DetectionRun{}, &model.DetectionCall{}, &model.Token{}}
|
||||
require.NoError(t, db.AutoMigrate(rows...))
|
||||
t.Cleanup(func() {
|
||||
for _, row := range rows {
|
||||
require.NoError(t, db.Migrator().DropTable(row))
|
||||
}
|
||||
model.DB = oldDB
|
||||
common.SetDatabaseTypes(oldType, oldLogType)
|
||||
groupDegradationSnapshot.updated = time.Time{}
|
||||
groupDegradationSnapshot.latest = nil
|
||||
conn, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, conn.Close())
|
||||
})
|
||||
var version string
|
||||
query := "select version()"
|
||||
if dialect == "sqlite" {
|
||||
query = "select sqlite_version()"
|
||||
}
|
||||
require.NoError(t, db.Raw(query).Scan(&version).Error)
|
||||
t.Log("database version:", version)
|
||||
now := time.Now().Unix()
|
||||
require.NoError(t, db.Create(&model.Channel{Id: 1, Group: "default", Models: fingerprint.Model, Status: 1, Key: "test-only"}).Error)
|
||||
body, err := common.Marshal(AstraSamplingState{Rounds: []AstraSamplingRound{{ID: "mismatch", ChannelID: 1, StartedAt: now - 30, FinishedAt: now - 10, Result: fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch", Closest: "gpt-5.5", ValidSamples: 2}}}})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.SystemTask{TaskID: "degradation-test", Type: AstraSamplingTaskType, Status: model.SystemTaskStatusSucceeded, CreatedAt: now - 30, State: string(body)}).Error)
|
||||
policy := &model.TokenRoutingPolicy{Version: 1, Mode: "custom", SkipDegradedGroups: true, Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}}}
|
||||
encoded, err := common.Marshal(policy)
|
||||
require.NoError(t, err)
|
||||
token := model.Token{UserId: 1, Key: "degradation-test-key", RoutingPolicy: string(encoded)}
|
||||
require.NoError(t, db.Create(&token).Error)
|
||||
require.NoError(t, db.First(&token, token.Id).Error)
|
||||
stored, err := token.GetRoutingPolicy()
|
||||
require.NoError(t, err)
|
||||
require.True(t, stored.SkipDegradedGroups)
|
||||
ctx := func() *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
c.Set(model.TokenRoutingContextKey, stored)
|
||||
return c
|
||||
}
|
||||
groupDegradationSnapshot.updated = time.Time{}
|
||||
assert.True(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
|
||||
smart := ctx()
|
||||
smartPolicy := &model.TokenRoutingPolicy{Version: 1, Mode: "smart"}
|
||||
smart.Set(model.TokenRoutingContextKey, smartPolicy)
|
||||
assert.True(t, requestGroupDegraded(smart, fingerprint.Model, "default"))
|
||||
smartPolicy.AllowDegraded = true
|
||||
assert.False(t, requestGroupDegraded(smart, fingerprint.Model, "default"))
|
||||
smartPolicy.AllowDegraded = false
|
||||
assert.False(t, requestGroupDegraded(smart, "unmeasured-model", "default"))
|
||||
manualResult, err := common.Marshal(fingerprint.Result{Engine: fingerprint.Version, Status: "match", Closest: fingerprint.Model, ValidSamples: 2})
|
||||
require.NoError(t, err)
|
||||
manual := model.DetectionRun{ID: "manual-recovery", RequestKey: "manual-recovery", ChannelID: 1, Kind: "fingerprint", UserID: 1, CreatedAt: now, Settled: true, Status: "succeeded", Result: string(manualResult)}
|
||||
require.NoError(t, db.Create(&manual).Error)
|
||||
groupDegradationSnapshot.updated = time.Time{}
|
||||
assert.False(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
|
||||
pelican := model.DetectionRun{ID: "pelican-degraded", RequestKey: "pelican-degraded", ChannelID: 1, Kind: "pelican", CreatedAt: now, Settled: true, Status: "succeeded", Quality: "degraded"}
|
||||
require.NoError(t, db.Create(&pelican).Error)
|
||||
groupDegradationSnapshot.updated = time.Time{}
|
||||
assert.False(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
|
||||
require.NoError(t, db.Model(&pelican).Update("quality", "normal").Error)
|
||||
groupDegradationSnapshot.updated = time.Time{}
|
||||
assert.False(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
|
||||
require.NoError(t, db.Delete(&manual).Error)
|
||||
require.NoError(t, model.SaveStatusPageGroup(ctx().Request.Context(), "default", nil))
|
||||
groupDegradationSnapshot.updated = time.Time{}
|
||||
assert.False(t, requestGroupDegraded(ctx(), fingerprint.Model, "default"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupDegradationUsesLatestValidBeforeHistoryTruncation(t *testing.T) {
|
||||
now := time.Now()
|
||||
channels := []model.Channel{{Id: 1, Group: "default", Models: fingerprint.Model, Status: 1}}
|
||||
rounds := []AstraSamplingRound{{ID: "valid", ChannelID: 1, StartedAt: now.Unix() - 100, FinishedAt: now.Unix() - 90, Result: fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch", Closest: "gpt-5.5", ValidSamples: 1}}}
|
||||
for i := range 15 {
|
||||
rounds = append(rounds, AstraSamplingRound{ChannelID: 1, StartedAt: now.Unix() - int64(i), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "error"}})
|
||||
}
|
||||
body, err := common.Marshal(AstraSamplingState{Rounds: rounds})
|
||||
require.NoError(t, err)
|
||||
tasks := []*model.SystemTask{{Status: model.SystemTaskStatusSucceeded, State: string(body)}}
|
||||
summary := AggregateAstraSampling(channels, tasks, now)["default"]
|
||||
require.NotNil(t, summary.LatestValid)
|
||||
assert.Equal(t, "gpt-5.5", summary.LatestValid.Closest)
|
||||
assert.Len(t, summary.History, 12)
|
||||
rounds = append(rounds, AstraSamplingRound{ID: "recovered", ChannelID: 1, StartedAt: now.Unix(), FinishedAt: now.Unix(), Result: fingerprint.Result{Engine: fingerprint.Version, Status: "match", Closest: fingerprint.Model, ValidSamples: 2}})
|
||||
body, err = common.Marshal(AstraSamplingState{Rounds: rounds})
|
||||
require.NoError(t, err)
|
||||
tasks[0].State = string(body)
|
||||
summary = AggregateAstraSampling(channels, tasks, now)["default"]
|
||||
require.NotNil(t, summary.LatestValid)
|
||||
assert.Equal(t, fingerprint.Model, summary.LatestValid.Closest)
|
||||
reset := aggregateAstraSampling(channels, tasks, now, map[string]model.StatusPageGroup{"default": {ResetBefore: now.Unix()}})
|
||||
assert.Nil(t, reset["default"].LatestValid)
|
||||
}
|
||||
|
||||
func TestGroupDegradationOverridesExistingPoolAffinity(t *testing.T) {
|
||||
db := setupChannelSelectAutoGroupsTest(t)
|
||||
createChannelSelectAutoGroupsChannel(t, db, 6401, "default", fingerprint.Model)
|
||||
createChannelSelectAutoGroupsChannel(t, db, 6402, "vip", fingerprint.Model)
|
||||
model.InitChannelCache()
|
||||
ResetGroupAvailability()
|
||||
t.Cleanup(ResetGroupAvailability)
|
||||
previousRedis := common.RedisEnabled
|
||||
common.RedisEnabled = false
|
||||
t.Cleanup(func() { common.RedisEnabled = previousRedis; _ = poolBindings().Purge() })
|
||||
require.NoError(t, poolBindings().Purge())
|
||||
config := operation_setting.GetChannelAffinitySetting()
|
||||
previous := *config
|
||||
*config = operation_setting.ChannelAffinitySetting{Enabled: true, DefaultTTLSeconds: 3600, SessionMode: "strict", Rules: []operation_setting.ChannelAffinityRule{{Name: "degradation-test", ModelRegex: []string{".*"}, KeySources: []operation_setting.ChannelAffinityKeySource{{Type: "request_header", Key: "Session-Id"}}, SessionMode: "strict"}}}
|
||||
t.Cleanup(func() { *config = previous })
|
||||
ctx := func(degraded bool) *gin.Context {
|
||||
c := setupGroupPoolContext(t)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
c.Request.Header.Set("Session-Id", "degradation-session")
|
||||
c.Set("id", 100)
|
||||
c.Set("token_id", 100)
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", SkipDegradedGroups: true, Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}, {Group: "vip", Priority: 1}}})
|
||||
rounds := map[string]AstraSamplingHistory{}
|
||||
if degraded {
|
||||
rounds["default"] = AstraSamplingHistory{At: time.Now().Unix(), Closest: "gpt-5.5", ValidSamples: 1}
|
||||
}
|
||||
c.Set("pool_group_fingerprints", rounds)
|
||||
return c
|
||||
}
|
||||
first := ctx(false)
|
||||
selected, group, err := SelectChannelForRequest(first, fingerprint.Model, &RetryParam{Ctx: first, TokenGroup: "default", ModelName: fingerprint.Model})
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, selected)
|
||||
require.Equal(t, 6401, selected.Id)
|
||||
first.Set("channel_id", selected.Id)
|
||||
common.SetContextKey(first, constant.ContextKeyAutoGroup, group)
|
||||
RequestPolicy(first).Successful = true
|
||||
RecordPoolAffinity(first)
|
||||
bound, _ := PreferredPoolChannel(ctx(false), fingerprint.Model)
|
||||
require.NotNil(t, bound)
|
||||
next := ctx(true)
|
||||
selected, group, err = SelectChannelForRequest(next, fingerprint.Model, &RetryParam{Ctx: next, TokenGroup: "default", ModelName: fingerprint.Model})
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, selected)
|
||||
assert.Equal(t, 6402, selected.Id)
|
||||
assert.Equal(t, "vip", group)
|
||||
}
|
||||
|
||||
func TestGroupDegradationSkipsPoolGroupsAndRespectsUnknownRecoveryAndOptOut(t *testing.T) {
|
||||
db := setupChannelSelectAutoGroupsTest(t)
|
||||
createChannelSelectAutoGroupsChannel(t, db, 6301, "default", fingerprint.Model)
|
||||
createChannelSelectAutoGroupsChannel(t, db, 6302, "vip", fingerprint.Model)
|
||||
model.InitChannelCache()
|
||||
ResetGroupAvailability()
|
||||
t.Cleanup(ResetGroupAvailability)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
closest string
|
||||
age time.Duration
|
||||
degradeBackup bool
|
||||
want int
|
||||
}{
|
||||
{"latest mismatch switches", true, "gpt-5.5", time.Minute, false, 6302},
|
||||
{"recovered group returns", true, fingerprint.Model, time.Minute, false, 6301},
|
||||
{"unknown stays eligible", true, "", time.Minute, false, 6301},
|
||||
{"valid result persists beyond window", true, "gpt-5.5", 7 * time.Hour, false, 6302},
|
||||
{"disabled keeps routing", false, "gpt-5.5", time.Minute, false, 6301},
|
||||
{"all degraded has no route", true, "gpt-5.5", time.Minute, true, 0},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
c := setupGroupPoolContext(t)
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", SkipDegradedGroups: test.enabled, Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}, {Group: "vip", Priority: 1}}})
|
||||
rounds := map[string]AstraSamplingHistory{"default": {At: time.Now().Add(-test.age).Unix(), Closest: test.closest, ValidSamples: 1}}
|
||||
if test.degradeBackup {
|
||||
rounds["vip"] = rounds["default"]
|
||||
}
|
||||
c.Set("pool_group_fingerprints", rounds)
|
||||
selected, _, err := selectTokenRoutingPool(&RetryParam{Ctx: c, TokenGroup: "default", ModelName: fingerprint.Model})
|
||||
require.NoError(t, err)
|
||||
if test.want == 0 {
|
||||
assert.Nil(t, selected)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, selected)
|
||||
assert.Equal(t, test.want, selected.Id)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
)
|
||||
|
||||
var groupModelCostSnapshot struct {
|
||||
sync.Mutex
|
||||
updated time.Time
|
||||
values map[string]map[string]model.GroupModelCost
|
||||
}
|
||||
|
||||
// Immutable snapshots are shared by status display and automatic routing.
|
||||
func GroupModelCosts(ctx context.Context) (map[string]map[string]model.GroupModelCost, error) {
|
||||
groupModelCostSnapshot.Lock()
|
||||
defer groupModelCostSnapshot.Unlock()
|
||||
now := time.Now()
|
||||
if groupModelCostSnapshot.values != nil && now.Sub(groupModelCostSnapshot.updated) < time.Minute {
|
||||
return groupModelCostSnapshot.values, nil
|
||||
}
|
||||
queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
values, err := model.GetGroupModelCosts(queryCtx, now.Add(-24*time.Hour), now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupModelCostSnapshot.values, groupModelCostSnapshot.updated = values, now
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
)
|
||||
|
||||
// Routing evidence has no statistical window. Each detector recovers separately.
|
||||
func readGroupQuality(ctx context.Context, now time.Time) (map[string]AstraSamplingHistory, map[string]DetectionHistory, error) {
|
||||
settings, err := model.GetStatusPageGroups(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
channels, err := samplingChannels(ctx, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
channelGroups := map[int][]string{}
|
||||
for _, channel := range channels {
|
||||
channelGroups[channel.Id] = channel.GetGroups()
|
||||
}
|
||||
latest, benchmarks := map[string]AstraSamplingHistory{}, map[string]DetectionHistory{}
|
||||
observe := func(channelID int, h AstraSamplingHistory) {
|
||||
if h.At <= 0 || h.At > now.Unix() || h.ValidSamples <= 0 || h.Closest == "" || (h.Status != "match" && h.Status != "mismatch") {
|
||||
return
|
||||
}
|
||||
for _, group := range channelGroups[channelID] {
|
||||
if h.At > settings[group].ResetBefore && h.At > latest[group].At {
|
||||
latest[group] = h
|
||||
}
|
||||
}
|
||||
}
|
||||
var tasks []model.SystemTask
|
||||
if err = model.DB.WithContext(ctx).Select("state").Where("type = ?", AstraSamplingTaskType).Find(&tasks).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
var state AstraSamplingState
|
||||
if task.DecodeState(&state) != nil {
|
||||
continue
|
||||
}
|
||||
for _, round := range state.Rounds {
|
||||
if round.FinishedAt <= 0 || round.Result.Engine != fingerprint.Version {
|
||||
continue
|
||||
}
|
||||
observe(round.ChannelID, AstraSamplingHistory{ID: round.ID, At: round.StartedAt, Status: AstraSamplingResultStatus(round.Result), ValidSamples: round.Result.ValidSamples, Closest: round.Result.Closest})
|
||||
}
|
||||
}
|
||||
var runs []model.DetectionRun
|
||||
if err = model.DB.WithContext(ctx).Select("id", "channel_id", "kind", "created_at", "quality", "result").Where("status = ? AND settled = ? AND created_at <= ?", "succeeded", true, now.Unix()).Find(&runs).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, run := range runs {
|
||||
if run.Kind == "fingerprint" {
|
||||
var result fingerprint.Result
|
||||
if common.UnmarshalJsonStr(run.Result, &result) != nil || result.Engine != fingerprint.Version {
|
||||
continue
|
||||
}
|
||||
observe(run.ChannelID, AstraSamplingHistory{ID: run.ID, At: run.CreatedAt, Status: AstraSamplingResultStatus(result), ValidSamples: result.ValidSamples, Closest: result.Closest})
|
||||
}
|
||||
|
||||
}
|
||||
return latest, benchmarks, nil
|
||||
}
|
||||
|
||||
func cachedGroupQuality(ctx context.Context) (map[string]AstraSamplingHistory, map[string]DetectionHistory, error) {
|
||||
groupDegradationSnapshot.Lock()
|
||||
defer groupDegradationSnapshot.Unlock()
|
||||
now := time.Now()
|
||||
if now.Sub(groupDegradationSnapshot.updated) >= 15*time.Second {
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
latest, benchmarks, err := readGroupQuality(ctx, now)
|
||||
cancel()
|
||||
if err == nil {
|
||||
groupDegradationSnapshot.latest, groupDegradationSnapshot.benchmark = latest, benchmarks
|
||||
} else if groupDegradationSnapshot.latest == nil {
|
||||
return nil, nil, errors.New("group quality temporarily unavailable")
|
||||
}
|
||||
groupDegradationSnapshot.updated = now
|
||||
}
|
||||
return groupDegradationSnapshot.latest, groupDegradationSnapshot.benchmark, nil
|
||||
}
|
||||
|
||||
func RecentDegradedGroups(ctx context.Context) (map[string]bool, error) {
|
||||
latest, _, err := cachedGroupQuality(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]bool{}
|
||||
cutoff := time.Now().Add(-AstraSamplingWindow).Unix()
|
||||
for group, h := range latest {
|
||||
if h.At >= cutoff && h.Closest != fingerprint.Model {
|
||||
result[group] = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func InvalidateGroupQualityCache() {
|
||||
groupDegradationSnapshot.Lock()
|
||||
defer groupDegradationSnapshot.Unlock()
|
||||
groupDegradationSnapshot.updated = time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQualitySwitchesAndFailedReadCache(t *testing.T) {
|
||||
db := setupChannelSelectAutoGroupsTest(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.Option{}, &model.SystemTask{}, &model.DetectionRun{}))
|
||||
createChannelSelectAutoGroupsChannel(t, db, 6501, "default", fingerprint.Model)
|
||||
old := time.Now().Add(-8 * time.Hour).Unix()
|
||||
result, err := common.Marshal(fingerprint.Result{Engine: fingerprint.Version, Status: "mismatch", ValidSamples: 3, Closest: "gpt-6-sol"})
|
||||
require.NoError(t, err)
|
||||
run := model.DetectionRun{Result: string(result), ID: "old-valid", RequestKey: "old-valid", ChannelID: 6501, Kind: "fingerprint", Status: "succeeded", Settled: true, Quality: "degraded", CreatedAt: old}
|
||||
require.NoError(t, db.Create(&run).Error)
|
||||
require.NoError(t, db.Create(&model.DetectionRun{ID: "failed", RequestKey: "failed", ChannelID: 6501, Kind: "pelican", Status: "failed", Settled: true, CreatedAt: time.Now().Unix()}).Error)
|
||||
groupDegradationSnapshot.latest = nil
|
||||
InvalidateGroupQualityCache()
|
||||
t.Cleanup(func() {
|
||||
groupDegradationSnapshot.latest = nil
|
||||
groupDegradationSnapshot.benchmark = nil
|
||||
InvalidateGroupQualityCache()
|
||||
})
|
||||
ctx := func(allow, child bool) *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart", AllowDegraded: allow, SkipDegradedNonAstra: child})
|
||||
return c
|
||||
}
|
||||
for _, allow := range []bool{false, true} {
|
||||
for _, child := range []bool{false, true} {
|
||||
require.Equal(t, !allow, requestGroupDegraded(ctx(allow, child), fingerprint.Model, "default"))
|
||||
require.Equal(t, !allow && child, requestGroupDegraded(ctx(allow, child), "gpt-6-sol", "default"))
|
||||
}
|
||||
}
|
||||
recent, err := RecentDegradedGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.False(t, recent["default"])
|
||||
require.NoError(t, db.Migrator().DropTable(&model.DetectionRun{}))
|
||||
InvalidateGroupQualityCache()
|
||||
require.True(t, requestGroupDegraded(ctx(false, false), fingerprint.Model, "default"), "read failure retains valid conclusion")
|
||||
groupDegradationSnapshot.latest = nil
|
||||
InvalidateGroupQualityCache()
|
||||
cold := ctx(false, false)
|
||||
require.True(t, requestGroupDegraded(cold, fingerprint.Model, "default"))
|
||||
require.True(t, cold.GetBool("pool_degradation_unavailable"))
|
||||
require.NoError(t, db.AutoMigrate(&model.DetectionRun{}))
|
||||
InvalidateGroupQualityCache()
|
||||
require.False(t, requestGroupDegraded(ctx(false, false), fingerprint.Model, "default"), "successful empty history remains eligible")
|
||||
}
|
||||
|
||||
func TestAbandonedPoolProbeIsReleasedWithoutClearingFailures(t *testing.T) {
|
||||
db := setupChannelSelectAutoGroupsTest(t)
|
||||
createChannelSelectAutoGroupsChannel(t, db, 7001, "vip", "m")
|
||||
model.InitChannelCache()
|
||||
c := setupGroupPoolContext(t)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
c.Set("id", 701)
|
||||
c.Set("token_id", 701)
|
||||
key := poolCooldownKey(c, "m", "default")
|
||||
require.NoError(t, poolCooldowns().SetWithTTL(key, poolCooldown{Until: time.Now().Add(-time.Second).UnixMilli()}, 0))
|
||||
t.Cleanup(func() { _, _ = poolCooldowns().DeleteMany([]string{key}) })
|
||||
require.True(t, claimPoolGroup(c, "m", "default"))
|
||||
require.True(t, poolGroupSkipped(c, "m", "default"))
|
||||
ReleasePoolProbes(c)
|
||||
require.False(t, poolGroupSkipped(c, "m", "default"))
|
||||
require.True(t, claimPoolGroup(c, "m", "default"))
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "smart"})
|
||||
RecordPoolRetryableFailure(c, "m", "default")
|
||||
ReleasePoolProbes(c)
|
||||
value, found, err := poolCooldowns().Get(key)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Greater(t, value.Until, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func TestManualPoolNonAstraDegradationSwitch(t *testing.T) {
|
||||
for _, mode := range []string{"inherit", "custom"} {
|
||||
for _, enabled := range []bool{false, true} {
|
||||
for _, nonAstra := range []bool{false, true} {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: mode, SkipDegradedGroups: enabled, SkipDegradedNonAstra: nonAstra})
|
||||
c.Set("pool_group_fingerprints", map[string]AstraSamplingHistory{"default": {Status: "mismatch", ValidSamples: 3, Closest: "gpt-6-sol"}})
|
||||
c.Set("pool_group_pelicans", map[string]DetectionHistory{"default": {Quality: "degraded"}})
|
||||
require.Equal(t, enabled, requestGroupDegraded(c, fingerprint.Model, "default"))
|
||||
require.Equal(t, enabled && nonAstra, requestGroupDegraded(c, "gpt-6-sol", "default"))
|
||||
require.False(t, requestGroupDegraded(c, "gpt-6-sol", "untested"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func imageGenerationPoolGroup(c *gin.Context) string {
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
group := settings.ImageGenerationGroup
|
||||
if group == "" || !IsUserSelectableGroup(common.GetContextKeyString(c, constant.ContextKeyUserGroup), group) {
|
||||
return ""
|
||||
}
|
||||
policy := RequestRoutingPolicy(c)
|
||||
if policy != nil {
|
||||
if policy.Mode == "user" {
|
||||
return ""
|
||||
}
|
||||
return group
|
||||
}
|
||||
if common.GetContextKeyString(c, constant.ContextKeyTokenGroup) == "auto" || common.GetContextKeyBool(c, constant.ContextKeyTokenCrossGroupRetry) {
|
||||
return group
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func imageGenerationRequestGroup(c *gin.Context, modelName string) string {
|
||||
if !slices.Contains(model_setting.GetGlobalSettings().ImageGenerationModels, modelName) {
|
||||
return ""
|
||||
}
|
||||
return imageGenerationPoolGroup(c)
|
||||
}
|
||||
|
||||
// The dedicated route is shared by selection, affinity and the routing preview.
|
||||
func WithImageGenerationGroup(c *gin.Context, modelName string, entries []model.TokenGroupEntry) []model.TokenGroupEntry {
|
||||
group := imageGenerationPoolGroup(c)
|
||||
if group == "" {
|
||||
return entries
|
||||
}
|
||||
if modelName != "" {
|
||||
if imageGenerationRequestGroup(c, modelName) != "" {
|
||||
return []model.TokenGroupEntry{{Group: group, Priority: 0}}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
priority := 0
|
||||
for _, entry := range entries {
|
||||
if entry.Group == group {
|
||||
return entries
|
||||
}
|
||||
priority = max(priority, entry.Priority+1)
|
||||
}
|
||||
return append(slices.Clone(entries), model.TokenGroupEntry{Group: group, Priority: priority})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDedicatedImageGenerationPoolSelection(t *testing.T) {
|
||||
db := setupChannelSelectAutoGroupsTest(t)
|
||||
cfg := model_setting.GetGlobalSettings()
|
||||
oldGroup, oldModels := cfg.ImageGenerationGroup, cfg.ImageGenerationModels
|
||||
t.Cleanup(func() { cfg.ImageGenerationGroup, cfg.ImageGenerationModels = oldGroup, oldModels })
|
||||
cfg.ImageGenerationGroup, cfg.ImageGenerationModels = "example-images", []string{"gpt-image-2"}
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP","example-images":"Images"}`))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":2,"example-images":1.25}`))
|
||||
createChannelSelectAutoGroupsChannel(t, db, 8001, "default", "gpt-image-2")
|
||||
createChannelSelectAutoGroupsChannel(t, db, 8002, "example-images", "gpt-image-2")
|
||||
model.InitChannelCache()
|
||||
ResetGroupAvailability()
|
||||
t.Cleanup(ResetGroupAvailability)
|
||||
for _, mode := range []string{"smart", "inherit", "custom"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/images/generations", nil)
|
||||
common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: mode, Groups: []model.TokenGroupEntry{{Group: "default", Priority: 0}}})
|
||||
entries := ResolveRequestPool(c, "gpt-image-2")
|
||||
require.Equal(t, []model.TokenGroupEntry{{Group: "example-images", Priority: 0}}, entries)
|
||||
channel, group, err := CacheGetRandomSatisfiedChannel(&RetryParam{Ctx: c, TokenGroup: "default", ModelName: "gpt-image-2", Retry: common.GetPointer(0)})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, channel)
|
||||
require.Equal(t, 8002, channel.Id)
|
||||
require.Equal(t, "example-images", group)
|
||||
})
|
||||
}
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/images/generations", nil)
|
||||
common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
|
||||
defaults := []model.TokenGroupEntry{{Group: "default", Priority: 0}}
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: defaults})
|
||||
require.Equal(t, defaults, ResolveRequestPool(c, "text-model"))
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "user"})
|
||||
require.Equal(t, defaults, ResolveRequestPool(c, "gpt-image-2"))
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: defaults})
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
|
||||
require.Equal(t, defaults, ResolveRequestPool(c, "gpt-image-2"))
|
||||
}
|
||||
@@ -100,7 +100,9 @@ func AppendRelayLogAdminInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
|
||||
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
|
||||
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) *model.LogOther {
|
||||
if !ctx.GetBool("attempt_checkpoint") && ctx.GetString("attempt_guard_result") == "" { MarkRequestPolicySuccess(ctx, relayInfo.StreamStatus) }
|
||||
if !ctx.GetBool("attempt_checkpoint") && ctx.GetString("attempt_guard_result") == "" {
|
||||
MarkRequestPolicySuccess(ctx, relayInfo.StreamStatus)
|
||||
}
|
||||
other := model.NewLogOther()
|
||||
other.SetPublic("model_ratio", modelRatio)
|
||||
other.SetPublic("group_ratio", groupRatio)
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/cachex"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/hot"
|
||||
)
|
||||
|
||||
const poolFailoverCooldown = 10 * time.Minute
|
||||
|
||||
type poolCooldown struct {
|
||||
Until int64 `json:"until"`
|
||||
ProbeUntil int64 `json:"probe_until"`
|
||||
ProbeID string `json:"probe_id"`
|
||||
}
|
||||
|
||||
var poolCooldownOnce sync.Once
|
||||
var poolCooldownCache *cachex.HybridCache[poolCooldown]
|
||||
var poolCooldownMu sync.Mutex
|
||||
|
||||
func poolCooldowns() *cachex.HybridCache[poolCooldown] {
|
||||
poolCooldownOnce.Do(func() {
|
||||
poolCooldownCache = cachex.NewHybridCache[poolCooldown](cachex.HybridCacheConfig[poolCooldown]{
|
||||
Namespace: cachex.Namespace("new-api:pool-failover:v3"), Redis: common.RDB,
|
||||
RedisEnabled: func() bool { return common.RedisEnabled && common.RDB != nil }, RedisCodec: cachex.JSONCodec[poolCooldown]{},
|
||||
Memory: func() *hot.HotCache[string, poolCooldown] {
|
||||
return hot.NewHotCache[string, poolCooldown](hot.LRU, 100000).Build()
|
||||
},
|
||||
})
|
||||
})
|
||||
return poolCooldownCache
|
||||
}
|
||||
func poolCooldownKey(c *gin.Context, modelName, group string) string {
|
||||
return common.GenerateHMAC(fmt.Sprintf("%d:%d:%t:%d:%s:%d:%s", c.GetInt("id"), c.GetInt("token_id"), c.GetBool("pool_request_stream"), len(modelName), modelName, len(group), group))
|
||||
}
|
||||
func poolFailures(c *gin.Context) map[string]int {
|
||||
if value, ok := c.Get("pool_failures"); ok {
|
||||
return value.(map[string]int)
|
||||
}
|
||||
value := map[string]int{}
|
||||
c.Set("pool_failures", value)
|
||||
return value
|
||||
}
|
||||
|
||||
// A cooldown may exclude a group only while another eligible route exists.
|
||||
// Inspect alternatives without claiming probes or recursively checking cooldowns.
|
||||
func hasPoolAlternative(c *gin.Context, modelName, current string) bool {
|
||||
for _, entry := range ResolveRequestPool(c, modelName) {
|
||||
group := entry.Group
|
||||
if group == current || poolFailures(c)[group] >= 2 || requestGroupDegraded(c, modelName, group) {
|
||||
continue
|
||||
}
|
||||
value, found, err := poolCooldowns().Get(poolCooldownKey(c, modelName, group))
|
||||
now := time.Now().UnixMilli()
|
||||
if err != nil || (found && (now < value.Until || now < value.ProbeUntil)) {
|
||||
continue
|
||||
}
|
||||
channel, err := model.GetRandomSatisfiedChannel(group, modelName, 0, GetChannelConstraints(c).Filters)
|
||||
if err == nil && channel != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func poolGroupSkipped(c *gin.Context, modelName, group string) bool {
|
||||
if poolFailures(c)[group] >= 2 {
|
||||
return true
|
||||
}
|
||||
if RequestRoutingPolicy(c) != nil && !hasPoolAlternative(c, modelName, group) {
|
||||
return false
|
||||
}
|
||||
value, found, err := poolCooldowns().Get(poolCooldownKey(c, modelName, group))
|
||||
if err != nil || !found {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
skipped := now < value.Until || now < value.ProbeUntil
|
||||
if skipped {
|
||||
wait := int((max(value.Until, value.ProbeUntil) - now + 999) / 1000)
|
||||
previous := c.GetInt("pool_retry_after")
|
||||
if previous == 0 || wait < previous {
|
||||
c.Set("pool_retry_after", wait)
|
||||
}
|
||||
}
|
||||
return skipped
|
||||
}
|
||||
|
||||
// A request rejected after selection or interrupted while streaming must not
|
||||
// leave its recovery reservation blocking subsequent requests for five minutes.
|
||||
func ReleasePoolProbes(c *gin.Context) {
|
||||
raw, ok := c.Get("pool_claimed_groups")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for group, key := range raw.(map[string]string) {
|
||||
observed, ok := c.Get("pool_probe:" + group)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trial := observed.(poolCooldown)
|
||||
poolCooldownMu.Lock()
|
||||
current, found, err := poolCooldowns().Get(key)
|
||||
if err == nil && found && current.Until == trial.Until && current.ProbeID == trial.ProbeID {
|
||||
current.ProbeUntil, current.ProbeID = 0, ""
|
||||
_ = poolCooldowns().SetWithTTL(key, current, 0)
|
||||
}
|
||||
poolCooldownMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Only the selected candidate can claim a half-open probe. The expired marker
|
||||
// survives the ten-minute cooldown so a failed probe never gets a second try.
|
||||
func claimPoolGroup(c *gin.Context, modelName, group string) bool {
|
||||
if RequestRoutingPolicy(c) != nil && !hasPoolAlternative(c, modelName, group) {
|
||||
return true
|
||||
}
|
||||
poolCooldownMu.Lock()
|
||||
defer poolCooldownMu.Unlock()
|
||||
key := poolCooldownKey(c, modelName, group)
|
||||
value, found, err := poolCooldowns().Get(key)
|
||||
if err != nil || !found {
|
||||
return true
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if now < value.Until || now < value.ProbeUntil {
|
||||
return false
|
||||
}
|
||||
value.ProbeUntil = now + int64((5*time.Minute)/time.Millisecond)
|
||||
value.ProbeID = fmt.Sprint(time.Now().UnixNano())
|
||||
if err := poolCooldowns().SetWithTTL(key, value, 0); err != nil {
|
||||
return false
|
||||
}
|
||||
c.Set("pool_probe:"+group, value)
|
||||
raw, ok := c.Get("pool_claimed_groups")
|
||||
claimed := map[string]string{}
|
||||
if ok {
|
||||
claimed = raw.(map[string]string)
|
||||
}
|
||||
claimed[group] = key
|
||||
c.Set("pool_claimed_groups", claimed)
|
||||
return true
|
||||
}
|
||||
|
||||
func IsPoolFailoverError(c *gin.Context, err *types.NewAPIError) bool {
|
||||
if _, pinned, _ := GetChannelConstraints(c).ResolvedPin(); pinned {
|
||||
return false
|
||||
}
|
||||
if err == nil || RequestRoutingPolicy(c) == nil || GetChannelConstraints(c).SuppressesRetry() || types.IsSkipRetryError(err) {
|
||||
return false
|
||||
}
|
||||
if c.Request != nil && c.Request.Context().Err() != nil {
|
||||
return false
|
||||
}
|
||||
if c.Writer != nil && c.Writer.Written() {
|
||||
return false
|
||||
}
|
||||
if err.StatusCode < 500 || err.StatusCode > 599 {
|
||||
return false
|
||||
}
|
||||
return !operation_setting.IsAlwaysSkipRetryStatusCode(err.StatusCode) && !operation_setting.IsAlwaysSkipRetryCode(err.GetErrorCode()) && operation_setting.ShouldRetryByStatusCode(err.StatusCode)
|
||||
}
|
||||
|
||||
func RecordPoolRetryableFailure(c *gin.Context, modelName, group string) {
|
||||
if _, pinned, _ := GetChannelConstraints(c).ResolvedPin(); pinned {
|
||||
return
|
||||
}
|
||||
if RequestRoutingPolicy(c) == nil || group == "" || GetChannelConstraints(c).SuppressesRetry() {
|
||||
return
|
||||
}
|
||||
failures := poolFailures(c)
|
||||
failures[group]++
|
||||
c.Set("pool_retry_group", group)
|
||||
if _, trial := c.Get("pool_probe:" + group); trial {
|
||||
failures[group] = 2
|
||||
}
|
||||
if failures[group] < 2 || !hasPoolAlternative(c, modelName, group) {
|
||||
return
|
||||
}
|
||||
poolCooldownMu.Lock()
|
||||
key := poolCooldownKey(c, modelName, group)
|
||||
previous, _, _ := poolCooldowns().Get(key)
|
||||
value := poolCooldown{Until: max(previous.Until+1, time.Now().Add(poolFailoverCooldown).UnixMilli())}
|
||||
if err := poolCooldowns().SetWithTTL(key, value, 0); err != nil {
|
||||
common.SysError("pool cooldown write failed: " + err.Error())
|
||||
}
|
||||
poolCooldownMu.Unlock()
|
||||
ClearPoolAffinity(c)
|
||||
}
|
||||
|
||||
func RecordPoolSuccess(c *gin.Context, modelName, group string) {
|
||||
raw, trial := c.Get("pool_probe:" + group)
|
||||
if !trial {
|
||||
return
|
||||
}
|
||||
observed := raw.(poolCooldown)
|
||||
poolCooldownMu.Lock()
|
||||
defer poolCooldownMu.Unlock()
|
||||
key := poolCooldownKey(c, modelName, group)
|
||||
current, found, err := poolCooldowns().Get(key)
|
||||
if err == nil && found && current.Until == observed.Until && current.ProbeID == observed.ProbeID {
|
||||
_, _ = poolCooldowns().DeleteMany([]string{key})
|
||||
}
|
||||
}
|
||||
|
||||
func PoolRetryBudget(c *gin.Context, modelName string) int {
|
||||
if _, pinned, _ := GetChannelConstraints(c).ResolvedPin(); pinned {
|
||||
return 0
|
||||
}
|
||||
if RequestRoutingPolicy(c) == nil || GetChannelConstraints(c).SuppressesRetry() {
|
||||
return 0
|
||||
}
|
||||
return max(0, 2*len(ResolveRequestPool(c, modelName))-1)
|
||||
}
|
||||
|
||||
func poolHigherPriorityRecovered(c *gin.Context, modelName, boundGroup string) bool {
|
||||
entries := ResolveRequestPool(c, modelName)
|
||||
priority := -1
|
||||
for _, entry := range entries {
|
||||
if entry.Group == boundGroup {
|
||||
priority = entry.Priority
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Priority >= priority {
|
||||
break
|
||||
}
|
||||
value, found, err := poolCooldowns().Get(poolCooldownKey(c, modelName, entry.Group))
|
||||
if err == nil && found && time.Now().UnixMilli() >= value.Until && !poolGroupSkipped(c, modelName, entry.Group) && !requestGroupDegraded(c, modelName, entry.Group) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PoolRetryExhausted(c *gin.Context, modelName, group string) bool {
|
||||
return poolFailures(c)[group] >= 2 && !hasPoolAlternative(c, modelName, group)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPoolWithoutAvailableBackupDoesNotCoolDown(t *testing.T) {
|
||||
db := setupChannelSelectAutoGroupsTest(t)
|
||||
createChannelSelectAutoGroupsChannel(t, db, 7801, "default", "m")
|
||||
createChannelSelectAutoGroupsChannel(t, db, 7802, "vip", "other")
|
||||
model.InitChannelCache()
|
||||
ResetGroupAvailability()
|
||||
t.Cleanup(func() { ResetGroupAvailability(); _ = poolCooldowns().Purge() })
|
||||
makeCtx := func() *gin.Context {
|
||||
c := setupGroupPoolContext(t)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
c.Set("token_id", 7801)
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: []model.TokenGroupEntry{{Group: "default"}, {Group: "vip", Priority: 1}}})
|
||||
return c
|
||||
}
|
||||
c := makeCtx()
|
||||
RecordPoolRetryableFailure(c, "m", "default")
|
||||
RecordPoolRetryableFailure(c, "m", "default")
|
||||
_, found, err := poolCooldowns().Get(poolCooldownKey(c, "m", "default"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, found)
|
||||
// An old cooldown and global health state must not strand a single eligible group.
|
||||
require.NoError(t, poolCooldowns().SetWithTTL(poolCooldownKey(c, "m", "default"), poolCooldown{Until: time.Now().Add(10 * time.Minute).UnixMilli()}, 0))
|
||||
for i := 0; i < 4; i++ {
|
||||
RecordGroupOutcome("default", false)
|
||||
}
|
||||
next := makeCtx()
|
||||
ch, group, err := selectTokenRoutingPool(&RetryParam{Ctx: next, ModelName: "m", Retry: common.GetPointer(0)})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ch)
|
||||
require.Equal(t, "default", group)
|
||||
AppendUsedChannel(next, ch.Id)
|
||||
RecordPoolRetryableFailure(next, "m", "default")
|
||||
ch, _, err = selectTokenRoutingPool(&RetryParam{Ctx: next, ModelName: "m", Retry: common.GetPointer(1)})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ch)
|
||||
AppendUsedChannel(next, ch.Id)
|
||||
RecordPoolRetryableFailure(next, "m", "default")
|
||||
ch, _, err = selectTokenRoutingPool(&RetryParam{Ctx: next, ModelName: "m", Retry: common.GetPointer(2)})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, ch, "do not loop indefinitely inside one request")
|
||||
}
|
||||
func TestAutomaticSpeedUsesLatestExactModelStreamingTest(t *testing.T) {
|
||||
db := setupChannelSelectAutoGroupsTest(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.Log{}))
|
||||
previous := model.LOG_DB
|
||||
model.LOG_DB = db
|
||||
t.Cleanup(func() { model.LOG_DB = previous })
|
||||
now := time.Now().Unix()
|
||||
samples := []model.Log{
|
||||
{ChannelId: 1, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now - 20, Other: `{"frt":500}`},
|
||||
{ChannelId: 1, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now - 10, Other: `{"frt":60000}`},
|
||||
{ChannelId: 1, Type: 2, TokenId: 9, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now, Other: `{"frt":1}`},
|
||||
{ChannelId: 2, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now - 5, Other: `{"frt":1200}`},
|
||||
{ChannelId: 2, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "other", IsStream: true, CreatedAt: now, Other: `{"frt":10}`},
|
||||
{ChannelId: 3, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: false, CreatedAt: now, Other: `{"frt":10}`},
|
||||
{ChannelId: 4, Type: 2, TokenId: 0, Content: "模型测试", ModelName: "m", IsStream: true, CreatedAt: now - 2*3600, Other: `{"frt":10}`},
|
||||
}
|
||||
require.NoError(t, db.Create(&samples).Error)
|
||||
speeds := latestModelTestSpeeds(context.Background(), "m")
|
||||
require.Equal(t, map[int]float64{1: 60000, 2: 1200}, speeds)
|
||||
}
|
||||
|
||||
func TestProbeAndStreamingCooldownsAreIsolated(t *testing.T) {
|
||||
db := setupChannelSelectAutoGroupsTest(t)
|
||||
createChannelSelectAutoGroupsChannel(t, db, 7901, "default", "m")
|
||||
createChannelSelectAutoGroupsChannel(t, db, 7902, "vip", "m")
|
||||
model.InitChannelCache()
|
||||
t.Cleanup(func() { _ = poolCooldowns().Purge() })
|
||||
makeCtx := func(stream bool) *gin.Context {
|
||||
c := setupGroupPoolContext(t)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
c.Set("token_id", 7901)
|
||||
c.Set("pool_request_stream", stream)
|
||||
c.Set(model.TokenRoutingContextKey, &model.TokenRoutingPolicy{Version: 1, Mode: "custom", Groups: []model.TokenGroupEntry{{Group: "default"}, {Group: "vip", Priority: 1}}})
|
||||
return c
|
||||
}
|
||||
probe := makeCtx(false)
|
||||
RecordPoolRetryableFailure(probe, "m", "default")
|
||||
RecordPoolRetryableFailure(probe, "m", "default")
|
||||
require.True(t, poolGroupSkipped(makeCtx(false), "m", "default"))
|
||||
require.False(t, poolGroupSkipped(makeCtx(true), "m", "default"))
|
||||
}
|
||||
+1
-15
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
@@ -103,21 +102,8 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
|
||||
textOutTokens := usage.OutputTokenDetails.TextTokens
|
||||
audioInputTokens := usage.InputTokenDetails.AudioTokens
|
||||
audioOutTokens := usage.OutputTokenDetails.AudioTokens
|
||||
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
|
||||
modelRatio, _, _ := ratio_setting.GetModelRatio(modelName)
|
||||
|
||||
autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup)
|
||||
if exists {
|
||||
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
|
||||
logger.LogDebug(ctx, "final group ratio: %f", groupRatio)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
actualGroupRatio := groupRatio
|
||||
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
if ok {
|
||||
actualGroupRatio = userGroupRatio
|
||||
}
|
||||
actualGroupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
|
||||
|
||||
quotaInfo := QuotaInfo{
|
||||
InputDetails: TokenDetails{
|
||||
|
||||
@@ -22,7 +22,13 @@ func DecideRelayRetry(c *gin.Context, err *types.NewAPIError, retryTimes int) Po
|
||||
if err == nil {
|
||||
return PolicyDecision{Action: "stop", Reason: "request_completed", Source: "system"}
|
||||
}
|
||||
if ShouldSkipRetryAfterChannelAffinityFailure(c) {
|
||||
if c.Request != nil && c.Request.Context().Err() != nil {
|
||||
return PolicyDecision{Action: "stop", Reason: "client_cancelled", Source: "system"}
|
||||
}
|
||||
if c.Writer != nil && c.Writer.Written() {
|
||||
return PolicyDecision{Action: "stop", Reason: "response_started", Source: "system"}
|
||||
}
|
||||
if ShouldSkipRetryAfterChannelAffinityFailure(c) && !IsPoolFailoverError(c, err) {
|
||||
source := RequestPolicy(c).SessionModeSource
|
||||
if source == "" {
|
||||
source = "session_rule"
|
||||
|
||||
+11
-10
@@ -11,19 +11,20 @@ import (
|
||||
)
|
||||
|
||||
type RequestDetails struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
SessionSource string `json:"session_source"`
|
||||
CorrelationKey string `json:"correlation_key,omitempty"`
|
||||
RequestedModel string `json:"requested_model,omitempty"`
|
||||
UpstreamModel string `json:"upstream_model,omitempty"`
|
||||
ReturnedModel string `json:"returned_model,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Status int `json:"status"`
|
||||
ModelGuard *relaycommon.ResponseModelGuard `json:"model_guard,omitempty"`
|
||||
RoutingPolicyFingerprint string `json:"routing_policy_fingerprint,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
SessionSource string `json:"session_source"`
|
||||
CorrelationKey string `json:"correlation_key,omitempty"`
|
||||
RequestedModel string `json:"requested_model,omitempty"`
|
||||
UpstreamModel string `json:"upstream_model,omitempty"`
|
||||
ReturnedModel string `json:"returned_model,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Status int `json:"status"`
|
||||
ModelGuard *relaycommon.ResponseModelGuard `json:"model_guard,omitempty"`
|
||||
}
|
||||
|
||||
func appendRequestDetails(c *gin.Context, info *relaycommon.RelayInfo, other *model.LogOther) {
|
||||
d := RequestDetails{SessionSource: "none", RequestedModel: c.GetString("original_model"), Status: c.Writer.Status()}
|
||||
d := RequestDetails{RoutingPolicyFingerprint: c.GetString("pool_affinity_policy_fingerprint"), SessionSource: "none", RequestedModel: c.GetString("original_model"), Status: c.Writer.Status()}
|
||||
if status, ok := other.Snapshot()["status_code"].(int); ok {
|
||||
d.Status = status
|
||||
}
|
||||
|
||||
@@ -145,6 +145,9 @@ func MarkRequestPolicySuccess(c *gin.Context, stream *relaycommon.StreamStatus)
|
||||
}
|
||||
state.OutcomeRecorded = true
|
||||
state.Successful = stream == nil || stream.IsNormalEnd() && !stream.HasErrors() && (stream.ResponseOutcome() == "" || stream.ResponseOutcome() == "completed")
|
||||
if state.Successful {
|
||||
RecordPoolSuccess(c, c.GetString("original_model"), common.GetContextKeyString(c, constant.ContextKeyAutoGroup))
|
||||
}
|
||||
decision := PolicyDecision{Action: "success", Reason: "request_completed", Source: "upstream"}
|
||||
if !state.Successful {
|
||||
decision = PolicyDecision{Action: "stop", Reason: "stream_not_successful", Source: "system"}
|
||||
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user