Author SHA1 Message Date
Codex aa6c3f50f4 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.
2026-09-11 16:06:20 +08:00
6 changed files with 108 additions and 4 deletions

No files matched your search

+3 -2
View File
@@ -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 回归测试和生产构建。
+2 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
v1.0.0-rc.36-custom.1
v1.0.0-rc.36-custom.2
+58
View File
@@ -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")
})
}
}
+12
View File
@@ -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)
@@ -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))
}