feat: add max_claude_code_version setting and disable auto-upgrade env var

Add maximum Claude Code version limit to complement the existing minimum
version check. Refactor the version cache from single-value to unified
bounds struct (min+max) with a single atomic.Value and singleflight group.

- Backend: new constant, struct field, cache refactor, validation (semver
  format + cross-validation max >= min), gateway enforcement, audit diff
- Frontend: settings UI input, TypeScript types, zh/en i18n
- Add CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 to all Claude Code
  tutorials on /keys page (unix/cmd/powershell/vscode settings.json)
This commit is contained in:
shaw
2026-03-20 09:10:01 +08:00
parent 0236b97d49
commit 01d8286bd9
11 changed files with 130 additions and 49 deletions

View File

@@ -125,6 +125,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
OpsQueryModeDefault: settings.OpsQueryModeDefault,
OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds,
MinClaudeCodeVersion: settings.MinClaudeCodeVersion,
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
BackendModeEnabled: settings.BackendModeEnabled,
})
@@ -199,6 +200,7 @@ type UpdateSettingsRequest struct {
OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"`
MinClaudeCodeVersion string `json:"min_claude_code_version"`
MaxClaudeCodeVersion string `json:"max_claude_code_version"`
// 分组隔离
AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"`
@@ -442,6 +444,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// 验证最高版本号格式(空字符串=禁用,或合法 semver
if req.MaxClaudeCodeVersion != "" {
if !semverPattern.MatchString(req.MaxClaudeCodeVersion) {
response.Error(c, http.StatusBadRequest, "max_claude_code_version must be empty or a valid semver (e.g. 3.0.0)")
return
}
}
// 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号
if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" {
if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 {
response.Error(c, http.StatusBadRequest, "max_claude_code_version must be greater than or equal to min_claude_code_version")
return
}
}
settings := &service.SystemSettings{
RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled,
@@ -488,6 +506,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableIdentityPatch: req.EnableIdentityPatch,
IdentityPatchPrompt: req.IdentityPatchPrompt,
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
MaxClaudeCodeVersion: req.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
BackendModeEnabled: req.BackendModeEnabled,
OpsMonitoringEnabled: func() bool {
@@ -588,6 +607,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault,
OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds,
MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion,
MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
BackendModeEnabled: updatedSettings.BackendModeEnabled,
})
@@ -744,6 +764,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
changed = append(changed, "min_claude_code_version")
}
if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion {
changed = append(changed, "max_claude_code_version")
}
if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling {
changed = append(changed, "allow_ungrouped_key_scheduling")
}

View File

@@ -79,6 +79,7 @@ type SystemSettings struct {
OpsMetricsIntervalSeconds int `json:"ops_metrics_interval_seconds"`
MinClaudeCodeVersion string `json:"min_claude_code_version"`
MaxClaudeCodeVersion string `json:"max_claude_code_version"`
// 分组隔离
AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"`

View File

@@ -1281,7 +1281,7 @@ func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarte
return true
}
// checkClaudeCodeVersion 检查 Claude Code 客户端版本是否满足最低要求
// checkClaudeCodeVersion 检查 Claude Code 客户端版本是否满足版本要求
// 仅对已识别的 Claude Code 客户端执行count_tokens 路径除外
func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool {
ctx := c.Request.Context()
@@ -1294,8 +1294,8 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool {
return true
}
minVersion := h.settingService.GetMinClaudeCodeVersion(ctx)
if minVersion == "" {
minVersion, maxVersion := h.settingService.GetClaudeCodeVersionBounds(ctx)
if minVersion == "" && maxVersion == "" {
return true // 未设置,不检查
}
@@ -1306,13 +1306,22 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool {
return false
}
if service.CompareVersions(clientVersion, minVersion) < 0 {
if minVersion != "" && service.CompareVersions(clientVersion, minVersion) < 0 {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error",
fmt.Sprintf("Your Claude Code version (%s) is below the minimum required version (%s). Please update: npm update -g @anthropic-ai/claude-code",
clientVersion, minVersion))
return false
}
if maxVersion != "" && service.CompareVersions(clientVersion, maxVersion) > 0 {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error",
fmt.Sprintf("Your Claude Code version (%s) exceeds the maximum allowed version (%s). "+
"Please downgrade: npm install -g @anthropic-ai/claude-code@%s && "+
"set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 to prevent auto-upgrade",
clientVersion, maxVersion, maxVersion))
return false
}
return true
}

View File

@@ -226,6 +226,9 @@ const (
// SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查)
SettingKeyMinClaudeCodeVersion = "min_claude_code_version"
// SettingKeyMaxClaudeCodeVersion 最高 Claude Code 版本号限制 (semver, 如 "3.0.0",空值=不检查)
SettingKeyMaxClaudeCodeVersion = "max_claude_code_version"
// SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false未分组 Key 返回 403
SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling"

View File

@@ -44,26 +44,27 @@ type SettingRepository interface {
Delete(ctx context.Context, key string) error
}
// cachedMinVersion 缓存最低 Claude Code 版本号进程内缓存60s TTL
type cachedMinVersion struct {
value string // 空字符串 = 不检查
// cachedVersionBounds 缓存 Claude Code 版本号上下限进程内缓存60s TTL
type cachedVersionBounds struct {
min string // 空字符串 = 不检查
max string // 空字符串 = 不检查
expiresAt int64 // unix nano
}
// minVersionCache 最低版本号进程内缓存
var minVersionCache atomic.Value // *cachedMinVersion
// versionBoundsCache 版本号上下限进程内缓存
var versionBoundsCache atomic.Value // *cachedVersionBounds
// minVersionSF 防止缓存过期时 thundering herd
var minVersionSF singleflight.Group
// versionBoundsSF 防止缓存过期时 thundering herd
var versionBoundsSF singleflight.Group
// minVersionCacheTTL 缓存有效期
const minVersionCacheTTL = 60 * time.Second
// versionBoundsCacheTTL 缓存有效期
const versionBoundsCacheTTL = 60 * time.Second
// minVersionErrorTTL DB 错误时的短缓存,快速重试
const minVersionErrorTTL = 5 * time.Second
// versionBoundsErrorTTL DB 错误时的短缓存,快速重试
const versionBoundsErrorTTL = 5 * time.Second
// minVersionDBTimeout singleflight 内 DB 查询超时,独立于请求 context
const minVersionDBTimeout = 5 * time.Second
// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context
const versionBoundsDBTimeout = 5 * time.Second
// cachedBackendMode Backend Mode cache (in-process, 60s TTL)
type cachedBackendMode struct {
@@ -484,6 +485,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
// Claude Code version check
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
// 分组隔离
updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling)
@@ -494,10 +496,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
err = s.settingRepo.SetMultiple(ctx, updates)
if err == nil {
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
minVersionSF.Forget("min_version")
minVersionCache.Store(&cachedMinVersion{
value: settings.MinClaudeCodeVersion,
expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(),
versionBoundsSF.Forget("version_bounds")
versionBoundsCache.Store(&cachedVersionBounds{
min: settings.MinClaudeCodeVersion,
max: settings.MaxClaudeCodeVersion,
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
})
backendModeSF.Forget("backend_mode")
backendModeCache.Store(&cachedBackendMode{
@@ -760,6 +763,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// Claude Code version check (default: empty = disabled)
SettingKeyMinClaudeCodeVersion: "",
SettingKeyMaxClaudeCodeVersion: "",
// 分组隔离(默认不允许未分组 Key 调度)
SettingKeyAllowUngroupedKeyScheduling: "false",
@@ -895,6 +899,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Claude Code version check
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
// 分组隔离
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
@@ -1281,51 +1286,61 @@ func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bo
return value == "true"
}
// GetMinClaudeCodeVersion 获取最低 Claude Code 版本号要求
// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求
// 使用进程内 atomic.Value 缓存60 秒 TTL热路径零锁开销
// singleflight 防止缓存过期时 thundering herd
// 返回空字符串表示不做版本检查
func (s *SettingService) GetMinClaudeCodeVersion(ctx context.Context) string {
if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok {
// 返回空字符串表示不做对应方向的版本检查
func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) {
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
if time.Now().UnixNano() < cached.expiresAt {
return cached.value
return cached.min, cached.max
}
}
// singleflight: 同一时刻只有一个 goroutine 查询 DB其余复用结果
result, err, _ := minVersionSF.Do("min_version", func() (any, error) {
type bounds struct{ min, max string }
result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) {
// 二次检查,避免排队的 goroutine 重复查询
if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok {
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
if time.Now().UnixNano() < cached.expiresAt {
return cached.value, nil
return bounds{cached.min, cached.max}, nil
}
}
// 使用独立 context断开请求取消链避免客户端断连导致空值被长期缓存
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), minVersionDBTimeout)
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout)
defer cancel()
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinClaudeCodeVersion)
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
SettingKeyMinClaudeCodeVersion,
SettingKeyMaxClaudeCodeVersion,
})
if err != nil {
// fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试
slog.Warn("failed to get min claude code version setting, skipping version check", "error", err)
minVersionCache.Store(&cachedMinVersion{
value: "",
expiresAt: time.Now().Add(minVersionErrorTTL).UnixNano(),
slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err)
versionBoundsCache.Store(&cachedVersionBounds{
min: "",
max: "",
expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(),
})
return "", nil
return bounds{"", ""}, nil
}
minVersionCache.Store(&cachedMinVersion{
value: value,
expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(),
b := bounds{
min: values[SettingKeyMinClaudeCodeVersion],
max: values[SettingKeyMaxClaudeCodeVersion],
}
versionBoundsCache.Store(&cachedVersionBounds{
min: b.min,
max: b.max,
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
})
return value, nil
return b, nil
})
if err != nil {
return ""
return "", ""
}
ver, ok := result.(string)
b, ok := result.(bounds)
if !ok {
return ""
return "", ""
}
return ver
return b.min, b.max
}
// GetRectifierSettings 获取请求整流器配置

View File

@@ -67,6 +67,7 @@ type SystemSettings struct {
// Claude Code version check
MinClaudeCodeVersion string
MaxClaudeCodeVersion string
// 分组隔离:允许未分组 Key 调度(默认 false → 403
AllowUngroupedKeyScheduling bool