From c2b44332d6ecd619945e81480372c763ea53875e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 16:06:20 +0800 Subject: [PATCH] feat: publish v1.0.0-rc.36-custom.2 Fixed-group tokens with a backup group now take precedence over the session affinity skip-retry-on-failure rule, so retryable upstream errors (503) fall through to the backup group instead of stopping on the primary channel. Non-retryable errors, explicit channel pins, and auto groups are unchanged. Adds controller regression tests for the affinity override and a build-time Go test gate. --- CUSTOMIZATION.md | 5 +- Dockerfile.request-status | 3 +- VERSION | 2 +- controller/fixed_backup_affinity_test.go | 58 ++++++++++++++++++++++ service/channel_affinity.go | 12 +++++ service/channel_select_auto_groups_test.go | 32 ++++++++++++ 6 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 controller/fixed_backup_affinity_test.go diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index b5eeb07..8f8d08a 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -1,11 +1,12 @@ # 自定义版本说明 -当前生产版本基于官方 `v1.0.0-rc.36`(`ea7cb0ba4e0f82e2bfa5e55752eb68bdf902f71b`),版本号为 `v1.0.0-rc.36-custom.1`。 +当前生产版本基于官方 `v1.0.0-rc.36`(`ea7cb0ba4e0f82e2bfa5e55752eb68bdf902f71b`),版本号为 `v1.0.0-rc.36-custom.2`。 包含以下生产定制: - 仅展示存在可用渠道的分组;禁用、无渠道和不可用分组不进入用户筛选。 - 固定令牌分组可配置一个备用分组;主分组无路由或发生可重试上游错误时按顺序切换,实际命中的分组用于计费和日志。 +- 固定令牌配置了备用分组时,备用分组切换优先于会话亲和规则的“失败后不重试”;上游返回可重试错误(如 503)时不再停在主分组渠道。不可重试错误、显式指定渠道和 auto 分组行为保持不变。 - 分组状态页展示请求成功率、首字延迟、平均延迟、TPS、请求缓存和令牌缓存指标。 - 支持按分组配置动态计费表达式,并在价格页按所选分组展示。 - 用户侧隐藏上游错误细节,完整错误仅保留在 root 日志范围;客户端断开不计为上游失败。 @@ -13,7 +14,7 @@ 生产镜像通过以下命令构建: ```sh -docker build --pull=false -f Dockerfile.request-status -t local/new-api:v1.0.0-rc.36-custom-20260911 . +docker build --pull=false -f Dockerfile.request-status -t local/new-api:v1.0.0-rc.36-custom.2-20260911 . ``` `Dockerfile.request-status` 会执行前端类型检查、相关 Vitest 与 oxlint 检查、Go 回归测试和生产构建。 diff --git a/Dockerfile.request-status b/Dockerfile.request-status index 5249f9c..a91c5e8 100644 --- a/Dockerfile.request-status +++ b/Dockerfile.request-status @@ -14,7 +14,7 @@ RUN npx --no-install oxlint -c .oxlintrc.json src/features/group-status src/feat RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) npm run build FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS build -ENV GO111MODULE=on CGO_ENABLED=0 GOWORK=off GOEXPERIMENT=greenteagc +ENV GOMAXPROCS=2 GOFLAGS=-p=2 GO111MODULE=on CGO_ENABLED=0 GOWORK=off GOEXPERIMENT=greenteagc ARG TARGETOS ARG TARGETARCH ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} @@ -25,6 +25,7 @@ RUN go mod download COPY . . COPY --from=webbuild /build/web/dist ./web/dist RUN go test ./pkg/perf_metrics ./model ./controller ./service ./relay/helper -run 'TestGroupBuckets|TestRequestStatus|TestGroupModelPricing|TestGetEnabledGroupSetRequiresEnabledAbilityAndChannel|TestSelectableGroupsRequireEnabledChannel|TestAddTokenPersistsFixedBackupGroup|TestUpdateTokenAutoGroupsTriStateAndFixedBackup|TestAddTokenRejectsInvalidFixedBackupGroup|TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty|TestCacheGetRandomSatisfiedChannelUsesFixedTokenBackupGroup|TestCacheGetRandomSatisfiedChannelSkipsUnavailableFixedPrimary|TestAppendStreamStatusTreatsClientGoneAsDisplayNormal|TestClientFacingOpenAIErrorHidesUpstreamDetails|TestClientFacingClaudeErrorHidesUpstreamDetails|TestClientFacingErrorKeepsLocalValidationMessage|TestPublicUpstreamErrorContent' -count=1 +RUN go test ./controller ./service -run 'TestFixedBackup|TestShouldSkipRetryAfterChannelAffinityFailure|TestShouldRetryHonorsPinRetryMode' -count=1 RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a diff --git a/VERSION b/VERSION index 518895d..fe2106a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.0-rc.36-custom.1 +v1.0.0-rc.36-custom.2 diff --git a/controller/fixed_backup_affinity_test.go b/controller/fixed_backup_affinity_test.go new file mode 100644 index 0000000..45ef18d --- /dev/null +++ b/controller/fixed_backup_affinity_test.go @@ -0,0 +1,58 @@ +package controller + +import ( + "errors" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/service" + "github.com/stretchr/testify/require" +) + +func TestFixedBackupRetryOverridesAffinityOnly(t *testing.T) { + configureTokenAutoGroupsTest(t, "2", "[]") + for _, tc := range []struct { + name string + group string + backups []string + cross bool + status int + skip bool + pinned bool + want bool + }{ + {"503 with backup", "default", []string{"vip"}, true, 503, false, false, true}, + {"no backup", "default", nil, true, 503, false, false, false}, + {"same group", "default", []string{"default"}, true, 503, false, false, false}, + {"revoked backup", "default", []string{"unavailable"}, true, 503, false, false, false}, + {"retry disabled", "default", []string{"vip"}, false, 503, false, false, false}, + {"auto unchanged", "auto", []string{"vip"}, true, 503, false, false, false}, + {"invalid request", "default", []string{"vip"}, true, 400, true, false, false}, + {"skip retry", "default", []string{"vip"}, true, 503, true, false, false}, + {"explicit channel pin", "default", []string{"vip"}, true, 503, false, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c := newPinRetryContext() + c.Set("channel_affinity_skip_retry_on_failure", true) + common.SetContextKey(c, constant.ContextKeyUserGroup, "default") + common.SetContextKey(c, constant.ContextKeyTokenGroup, tc.group) + common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, tc.backups) + common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, tc.cross) + if tc.pinned { + service.GetChannelConstraints(c).AddPin(dto.ChannelPin{ + ChannelId: 13, Source: dto.PinSourceToken, + Rank: dto.PinRankToken, RetryMode: dto.PinRetrySingleAttempt, + }) + } + err := types.NewOpenAIError(errors.New("upstream unavailable"), types.ErrorCodeBadResponseStatusCode, tc.status) + if tc.skip { + err = types.NewOpenAIError(errors.New("do not replay"), types.ErrorCodeBadResponseStatusCode, tc.status, types.ErrOptionWithSkipRetry()) + } + require.Equal(t, tc.want, shouldRetry(c, err, 1)) + require.False(t, shouldRetry(c, err, 0), "exhausted retry budget stays bounded") + }) + } +} diff --git a/service/channel_affinity.go b/service/channel_affinity.go index ac61653..83a7557 100644 --- a/service/channel_affinity.go +++ b/service/channel_affinity.go @@ -11,6 +11,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/cachex" "github.com/QuantumNous/new-api/relaykit/dto" @@ -627,6 +628,17 @@ func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool { if c == nil { return false } + // Explicit fixed-token fallback takes precedence over cache affinity. + primary := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + if primary != "" && primary != "auto" && common.GetContextKeyBool(c, constant.ContextKeyTokenCrossGroupRetry) { + if groups, ok := common.GetContextKeyType[[]string](c, constant.ContextKeyTokenAutoGroups); ok { + for _, group := range FilterUserTokenAutoGroups(common.GetContextKeyString(c, constant.ContextKeyUserGroup), groups) { + if group != primary { + return false + } + } + } + } v, ok := c.Get(ginKeyChannelAffinitySkipRetry) if ok { b, ok := v.(bool) diff --git a/service/channel_select_auto_groups_test.go b/service/channel_select_auto_groups_test.go index 288f07c..f236ccf 100644 --- a/service/channel_select_auto_groups_test.go +++ b/service/channel_select_auto_groups_test.go @@ -193,3 +193,35 @@ func TestCacheGetRandomSatisfiedChannelSkipsUnavailableFixedPrimary(t *testing.T assert.Equal(t, "vip", selectedGroup) assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup)) } + +func TestFixedBackupAfterAffinityFailureWithOneRetry(t *testing.T) { + db := setupChannelSelectAutoGroupsTest(t) + common.RetryTimes = 1 + const modelName = "fixed-backup-affinity" + createChannelSelectAutoGroupsChannel(t, db, 13, "default", modelName) + createChannelSelectAutoGroupsChannel(t, db, 14, "vip", modelName) + model.InitChannelCache() + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"}) + common.SetContextKey(ctx, constant.ContextKeyTokenCrossGroupRetry, true) + ctx.Set(ginKeyChannelAffinitySkipRetry, true) + require.False(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx)) + + // The distributor's affinity hit bypasses the ordered group selector. + param := &RetryParam{Ctx: ctx, TokenGroup: "default", ModelName: modelName} + param.SetRetry(1) + first, _, err := CacheGetRandomSatisfiedChannel(param) + require.NoError(t, err) + require.NotNil(t, first) + require.Equal(t, 13, first.Id) + require.Equal(t, 0, param.GetRetry()) + param.IncreaseRetry() + backup, group, err := CacheGetRandomSatisfiedChannel(param) + require.NoError(t, err) + require.NotNil(t, backup) + require.Equal(t, 14, backup.Id) + require.Equal(t, "vip", group) + require.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup)) +}