From ffc8cf56db1729ef82bac961070261393a473f54 Mon Sep 17 00:00:00 2001 From: cneicy Date: Tue, 6 Oct 2026 00:07:10 +0800 Subject: [PATCH] feat: sync deployed v1.0.0-rc.38-custom.67 --- CUSTOMIZATION.md | 4 +- Dockerfile.affinity-switch | 3 + Dockerfile.browser-performance | 4 + Dockerfile.candy-test | 3 + Dockerfile.ccswitch | 3 + Dockerfile.combined | 3 + Dockerfile.downgrade-rate | 3 + Dockerfile.fingerprint-color | 3 + Dockerfile.group-degradation | 3 + Dockerfile.local-release | 2 +- Dockerfile.meow | 9 + Dockerfile.modeltrace | 10 + Dockerfile.stream-health | 3 + THIRD-PARTY-LICENSES.md | 7 + VERSION | 2 +- controller/astra_sampling.go | 49 +- controller/astra_sampling_test.go | 14 +- controller/automatic_model_list_test.go | 80 + controller/channel-test.go | 5 +- controller/detection.go | 25 +- controller/detection_test.go | 217 + controller/detection_worker.go | 246 +- controller/group.go | 22 +- controller/group_configuration.go | 86 + controller/group_configuration_test.go | 65 + controller/live_status_test.go | 4 +- controller/manxue_artwork_url_test.go | 19 - controller/manxue_public.go | 64 - controller/manxue_public_test.go | 80 - controller/model.go | 7 +- controller/pelican_fallback.go | 96 - controller/pelican_fallback_test.go | 14 - controller/pelican_local_integration_test.go | 50 - controller/pricing.go | 9 +- controller/relay.go | 19 +- controller/responses_websocket_test.go | 82 +- controller/status_page.go | 24 + controller/token_routing.go | 11 +- controller/uptime_kuma.go | 51 +- controller/uptime_kuma_test.go | 52 +- docs/error-log-group-custom19.md | 1 + middleware/auth.go | 5 + middleware/distributor.go | 27 +- model/group_metadata.go | 74 + model/group_model_cost.go | 86 + model/group_status_test.go | 58 + model/main.go | 2 + model/personal_group_ratio.go | 87 + model/personal_group_ratio_test.go | 96 + model/token_routing.go | 17 +- .../image_generation_price_test.go | 22 + pkg/model_benchmark/LICENSE-meow.txt | 75 + pkg/model_benchmark/fingerprint.go | 200 + pkg/model_benchmark/fingerprint_test.go | 73 + pkg/model_benchmark/meow.json | 2470 + pkg/model_benchmark/response.go | 144 + .../testdata/astra-reference.json | 1 + .../testdata/meow-reference.json | 1200 + pkg/model_fingerprint/LICENSE-meow.txt | 75 + pkg/model_fingerprint/bank.json | 44604 +++++++++------- pkg/model_fingerprint/fingerprint.go | 106 +- pkg/model_fingerprint/fingerprint_test.go | 71 +- pkg/model_fingerprint/meow.json | 2470 + pkg/model_fingerprint/response.go | 22 +- .../testdata/meow-reference.json | 1200 + .../testdata/modeltrace-reference.json | 1 + pkg/perf_metrics/live_status.go | 4 +- pkg/perf_metrics/live_status_test.go | 30 +- pkg/perf_metrics/metrics.go | 3 +- relay/helper/image_generation_resolution.go | 59 + .../image_generation_resolution_test.go | 29 + relay/helper/model_mapped.go | 4 + relay/helper/price.go | 24 +- relay/responses_websocket.go | 20 +- router/api-router.go | 7 +- service/astra_sampling.go | 20 +- service/astra_sampling_test.go | 47 +- service/automatic_group.go | 209 + service/automatic_models_test.go | 97 + service/candy.go | 19 + service/channel_select.go | 21 +- service/detection.go | 17 +- service/detection_schedule.go | 2 +- service/detection_schedule_test.go | 45 +- service/detection_status.go | 88 +- service/group_availability.go | 2 +- service/group_degradation.go | 48 + service/group_degradation_test.go | 234 + service/group_model_cost.go | 33 + service/group_quality.go | 108 + service/group_quality_test.go | 100 + service/image_generation_group.go | 58 + service/image_generation_group_test.go | 55 + service/log_info_generate.go | 4 +- service/pool_failover.go | 247 + service/pool_no_backup_test.go | 95 + service/quota.go | 16 +- service/relay_error.go | 8 +- service/request_details.go | 21 +- service/request_policy.go | 3 + service/responses_usage.go | 10 +- service/task_billing.go | 5 + service/task_billing_test.go | 22 + service/text_quota.go | 12 +- service/tiered_settle.go | 4 +- service/token_routing.go | 83 +- service/token_routing_switch_test.go | 116 + service/token_routing_test.go | 144 + setting/model_setting/global.go | 2 + .../__tests__/page-transition.test.tsx | 36 + web/src/features/dashboard/types.ts | 14 +- .../__tests__/adaptive-schedule.test.tsx | 6 +- .../group-status/__tests__/detection.test.tsx | 92 +- .../group-status/__tests__/metrics.test.tsx | 19 +- .../__tests__/status-page.test.tsx | 163 +- .../features/group-status/detection-panel.tsx | 184 +- .../group-status/fingerprint-panel.tsx | 10 +- web/src/features/group-status/index.tsx | 5 +- .../group-status/status-page-settings.tsx | 2 +- web/src/features/group-status/status-page.tsx | 302 +- .../__tests__/api-key-listing.test.tsx | 40 +- .../__tests__/api-keys-mutate-drawer.test.tsx | 20 + .../automatic-routing-settings.test.tsx | 95 + .../__tests__/cc-switch-selection.test.tsx | 88 + .../__tests__/group-personalization.test.tsx | 85 + .../components/api-key-group-combobox.tsx | 62 +- .../keys/components/api-keys-clock-context.ts | 21 + .../keys/components/api-keys-clock.tsx | 32 + .../keys/components/api-keys-columns.tsx | 370 +- .../components/api-keys-mutate-drawer.tsx | 68 +- .../keys/components/api-keys-table.tsx | 98 +- .../keys/components/astra-cost-notice.tsx | 28 +- .../keys/components/auto-group-visuals.tsx | 50 +- .../components/automatic-routing-settings.tsx | 163 + .../components/data-table-bulk-actions.tsx | 4 +- .../components/dialogs/cc-switch-dialog.tsx | 78 +- .../features/keys/components/group-tags.tsx | 28 + .../keys/components/routing-pool-editor.tsx | 322 +- web/src/features/keys/lib/api-key-form.ts | 2 + web/src/features/keys/types.ts | 6 +- .../models/group-ratio-form.tsx | 38 +- .../models/group-tags-editor.tsx | 165 + .../components/personal-group-ratios.tsx | 159 + .../users/components/users-mutate-drawer.tsx | 40 +- .../i18n/__tests__/automatic-routing.test.ts | 32 + web/src/i18n/locales/en.json | 82 +- web/src/i18n/locales/fr.json | 25 +- web/src/i18n/locales/ja.json | 25 +- web/src/i18n/locales/ru.json | 25 +- web/src/i18n/locales/vi.json | 25 +- web/src/i18n/locales/zh-TW.json | 25 +- web/src/i18n/locales/zh.json | 82 +- web/src/lib/api.ts | 12 +- web/src/lib/motion.ts | 6 +- 154 files changed, 39540 insertions(+), 20509 deletions(-) create mode 100644 Dockerfile.affinity-switch create mode 100644 Dockerfile.browser-performance create mode 100644 Dockerfile.candy-test create mode 100644 Dockerfile.ccswitch create mode 100644 Dockerfile.combined create mode 100644 Dockerfile.downgrade-rate create mode 100644 Dockerfile.fingerprint-color create mode 100644 Dockerfile.group-degradation create mode 100644 Dockerfile.meow create mode 100644 Dockerfile.modeltrace create mode 100644 Dockerfile.stream-health create mode 100644 controller/automatic_model_list_test.go create mode 100644 controller/group_configuration.go create mode 100644 controller/group_configuration_test.go delete mode 100644 controller/manxue_artwork_url_test.go delete mode 100644 controller/manxue_public.go delete mode 100644 controller/manxue_public_test.go delete mode 100644 controller/pelican_fallback.go delete mode 100644 controller/pelican_fallback_test.go delete mode 100644 controller/pelican_local_integration_test.go create mode 100644 model/group_metadata.go create mode 100644 model/group_model_cost.go create mode 100644 model/personal_group_ratio.go create mode 100644 model/personal_group_ratio_test.go create mode 100644 pkg/billingexpr/image_generation_price_test.go create mode 100644 pkg/model_benchmark/LICENSE-meow.txt create mode 100644 pkg/model_benchmark/fingerprint.go create mode 100644 pkg/model_benchmark/fingerprint_test.go create mode 100644 pkg/model_benchmark/meow.json create mode 100644 pkg/model_benchmark/response.go create mode 100644 pkg/model_benchmark/testdata/astra-reference.json create mode 100644 pkg/model_benchmark/testdata/meow-reference.json create mode 100644 pkg/model_fingerprint/LICENSE-meow.txt create mode 100644 pkg/model_fingerprint/meow.json create mode 100644 pkg/model_fingerprint/testdata/meow-reference.json create mode 100644 pkg/model_fingerprint/testdata/modeltrace-reference.json create mode 100644 relay/helper/image_generation_resolution.go create mode 100644 relay/helper/image_generation_resolution_test.go create mode 100644 service/automatic_group.go create mode 100644 service/automatic_models_test.go create mode 100644 service/candy.go create mode 100644 service/group_degradation.go create mode 100644 service/group_degradation_test.go create mode 100644 service/group_model_cost.go create mode 100644 service/group_quality.go create mode 100644 service/group_quality_test.go create mode 100644 service/image_generation_group.go create mode 100644 service/image_generation_group_test.go create mode 100644 service/pool_failover.go create mode 100644 service/pool_no_backup_test.go create mode 100644 service/token_routing_switch_test.go create mode 100644 web/src/components/__tests__/page-transition.test.tsx create mode 100644 web/src/features/keys/components/__tests__/automatic-routing-settings.test.tsx create mode 100644 web/src/features/keys/components/__tests__/cc-switch-selection.test.tsx create mode 100644 web/src/features/keys/components/__tests__/group-personalization.test.tsx create mode 100644 web/src/features/keys/components/api-keys-clock-context.ts create mode 100644 web/src/features/keys/components/api-keys-clock.tsx create mode 100644 web/src/features/keys/components/automatic-routing-settings.tsx create mode 100644 web/src/features/keys/components/group-tags.tsx create mode 100644 web/src/features/system-settings/models/group-tags-editor.tsx create mode 100644 web/src/features/users/components/personal-group-ratios.tsx create mode 100644 web/src/i18n/__tests__/automatic-routing.test.ts diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index 4f45816..88b6816 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -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 环境变量,也应同步更新。 diff --git a/Dockerfile.affinity-switch b/Dockerfile.affinity-switch new file mode 100644 index 0000000..2e12b92 --- /dev/null +++ b/Dockerfile.affinity-switch @@ -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" diff --git a/Dockerfile.browser-performance b/Dockerfile.browser-performance new file mode 100644 index 0000000..f8b6166 --- /dev/null +++ b/Dockerfile.browser-performance @@ -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" diff --git a/Dockerfile.candy-test b/Dockerfile.candy-test new file mode 100644 index 0000000..992f78e --- /dev/null +++ b/Dockerfile.candy-test @@ -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" diff --git a/Dockerfile.ccswitch b/Dockerfile.ccswitch new file mode 100644 index 0000000..9d2bb83 --- /dev/null +++ b/Dockerfile.ccswitch @@ -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" diff --git a/Dockerfile.combined b/Dockerfile.combined new file mode 100644 index 0000000..0cc08c9 --- /dev/null +++ b/Dockerfile.combined @@ -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" diff --git a/Dockerfile.downgrade-rate b/Dockerfile.downgrade-rate new file mode 100644 index 0000000..1e380c5 --- /dev/null +++ b/Dockerfile.downgrade-rate @@ -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" diff --git a/Dockerfile.fingerprint-color b/Dockerfile.fingerprint-color new file mode 100644 index 0000000..068c5d1 --- /dev/null +++ b/Dockerfile.fingerprint-color @@ -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" diff --git a/Dockerfile.group-degradation b/Dockerfile.group-degradation new file mode 100644 index 0000000..117bbb6 --- /dev/null +++ b/Dockerfile.group-degradation @@ -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" diff --git a/Dockerfile.local-release b/Dockerfile.local-release index fd9b3c9..773dc0a 100644 --- a/Dockerfile.local-release +++ b/Dockerfile.local-release @@ -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"] diff --git a/Dockerfile.meow b/Dockerfile.meow new file mode 100644 index 0000000..8d1c5d7 --- /dev/null +++ b/Dockerfile.meow @@ -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"] diff --git a/Dockerfile.modeltrace b/Dockerfile.modeltrace new file mode 100644 index 0000000..b24300c --- /dev/null +++ b/Dockerfile.modeltrace @@ -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"] diff --git a/Dockerfile.stream-health b/Dockerfile.stream-health new file mode 100644 index 0000000..b678637 --- /dev/null +++ b/Dockerfile.stream-health @@ -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" diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md index 3f992f6..bc91103 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -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. diff --git a/VERSION b/VERSION index 5a8c4d4..1a9a540 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.0-rc.38-custom.44-20260926 \ No newline at end of file +v1.0.0-rc.38-custom.67 diff --git a/controller/astra_sampling.go b/controller/astra_sampling.go index 5262c70..2747775 100644 --- a/controller/astra_sampling.go +++ b/controller/astra_sampling.go @@ -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 diff --git a/controller/astra_sampling_test.go b/controller/astra_sampling_test.go index 80d92b0..9386109 100644 --- a/controller/astra_sampling_test.go +++ b/controller/astra_sampling_test.go @@ -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) } } diff --git a/controller/automatic_model_list_test.go b/controller/automatic_model_list_test.go new file mode 100644 index 0000000..303558f --- /dev/null +++ b/controller/automatic_model_list_test.go @@ -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) + } + } + } +} diff --git a/controller/channel-test.go b/controller/channel-test.go index 1248853..7a15edc 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -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) diff --git a/controller/detection.go b/controller/detection.go index 9af628b..50ef0ae 100644 --- a/controller/detection.go +++ b/controller/detection.go @@ -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" diff --git a/controller/detection_test.go b/controller/detection_test.go index 54cd7fc..b07a502 100644 --- a/controller/detection_test.go +++ b/controller/detection_test.go @@ -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)) }) + } +} diff --git a/controller/detection_worker.go b/controller/detection_worker.go index 64cfbdb..90a8e8c 100644 --- a/controller/detection_worker.go +++ b/controller/detection_worker.go @@ -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< 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", "") diff --git a/controller/group.go b/controller/group.go index cd053fa..81cde8a 100644 --- a/controller/group.go +++ b/controller/group.go @@ -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, } } } diff --git a/controller/group_configuration.go b/controller/group_configuration.go new file mode 100644 index 0000000..14971ff --- /dev/null +++ b/controller/group_configuration.go @@ -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}) +} diff --git a/controller/group_configuration_test.go b/controller/group_configuration_test.go new file mode 100644 index 0000000..0499a9f --- /dev/null +++ b/controller/group_configuration_test.go @@ -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) +} diff --git a/controller/live_status_test.go b/controller/live_status_test.go index 8281493..ffd9488 100644 --- a/controller/live_status_test.go +++ b/controller/live_status_test.go @@ -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) }) } diff --git a/controller/manxue_artwork_url_test.go b/controller/manxue_artwork_url_test.go deleted file mode 100644 index 898eca6..0000000 --- a/controller/manxue_artwork_url_test.go +++ /dev/null @@ -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(``) - assert.Contains(t, result, "