From 01d8286bd9e48bdf24e0d1cb8322cf304f833020 Mon Sep 17 00:00:00 2001
From: shaw
Date: Fri, 20 Mar 2026 09:10:01 +0800
Subject: [PATCH] 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)
---
.../internal/handler/admin/setting_handler.go | 23 +++++
backend/internal/handler/dto/settings.go | 1 +
backend/internal/handler/gateway_handler.go | 17 +++-
backend/internal/service/domain_constants.go | 3 +
backend/internal/service/setting_service.go | 95 +++++++++++--------
backend/internal/service/settings_view.go | 1 +
frontend/src/api/admin/settings.ts | 2 +
frontend/src/components/keys/UseKeyModal.vue | 10 +-
frontend/src/i18n/locales/en.ts | 6 +-
frontend/src/i18n/locales/zh.ts | 5 +-
frontend/src/views/admin/SettingsView.vue | 16 ++++
11 files changed, 130 insertions(+), 49 deletions(-)
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 25456bb3..c91566c8 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -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")
}
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index b953e336..0f4f8fdc 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -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"`
diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go
index b5250aad..e1b1b9a8 100644
--- a/backend/internal/handler/gateway_handler.go
+++ b/backend/internal/handler/gateway_handler.go
@@ -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
}
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index 7b629e14..384d5159 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -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"
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index ece95c4e..f652839c 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -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 获取请求整流器配置
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 23188a09..cd0bed0b 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -67,6 +67,7 @@ type SystemSettings struct {
// Claude Code version check
MinClaudeCodeVersion string
+ MaxClaudeCodeVersion string
// 分组隔离:允许未分组 Key 调度(默认 false → 403)
AllowUngroupedKeyScheduling bool
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index a2cd67f0..0519d2fc 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -81,6 +81,7 @@ export interface SystemSettings {
// Claude Code version check
min_claude_code_version: string
+ max_claude_code_version: string
// 分组隔离
allow_ungrouped_key_scheduling: boolean
@@ -137,6 +138,7 @@ export interface UpdateSettingsRequest {
ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string
ops_metrics_interval_seconds?: number
min_claude_code_version?: string
+ max_claude_code_version?: string
allow_ungrouped_key_scheduling?: boolean
}
diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue
index b478c50a..634db115 100644
--- a/frontend/src/components/keys/UseKeyModal.vue
+++ b/frontend/src/components/keys/UseKeyModal.vue
@@ -441,17 +441,20 @@ function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] {
case 'unix':
path = 'Terminal'
content = `export ANTHROPIC_BASE_URL="${baseUrl}"
-export ANTHROPIC_AUTH_TOKEN="${apiKey}"`
+export ANTHROPIC_AUTH_TOKEN="${apiKey}"
+export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`
break
case 'cmd':
path = 'Command Prompt'
content = `set ANTHROPIC_BASE_URL=${baseUrl}
-set ANTHROPIC_AUTH_TOKEN=${apiKey}`
+set ANTHROPIC_AUTH_TOKEN=${apiKey}
+set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`
break
case 'powershell':
path = 'PowerShell'
content = `$env:ANTHROPIC_BASE_URL="${baseUrl}"
-$env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
+$env:ANTHROPIC_AUTH_TOKEN="${apiKey}"
+$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`
break
default:
path = 'Terminal'
@@ -466,6 +469,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
"env": {
"ANTHROPIC_BASE_URL": "${baseUrl}",
"ANTHROPIC_AUTH_TOKEN": "${apiKey}",
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
}
}`
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index ba90e11a..f04029e2 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -4119,7 +4119,11 @@ export default {
minVersion: 'Minimum Version',
minVersionPlaceholder: 'e.g. 2.1.63',
minVersionHint:
- 'Reject Claude Code clients below this version (semver format). Leave empty to disable version check.'
+ 'Reject Claude Code clients below this version (semver format). Leave empty to disable version check.',
+ maxVersion: 'Maximum Version',
+ maxVersionPlaceholder: 'e.g. 2.5.0',
+ maxVersionHint:
+ 'Reject Claude Code clients above this version (semver format). Leave empty to allow any version.'
},
scheduling: {
title: 'Gateway Scheduling Settings',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 8180c568..2c3eabeb 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -4283,7 +4283,10 @@ export default {
description: '控制 Claude Code 客户端访问要求',
minVersion: '最低版本号',
minVersionPlaceholder: '例如 2.1.63',
- minVersionHint: '拒绝低于此版本的 Claude Code 客户端请求(semver 格式)。留空则不检查版本。'
+ minVersionHint: '拒绝低于此版本的 Claude Code 客户端请求(semver 格式)。留空则不检查版本。',
+ maxVersion: '最高版本号',
+ maxVersionPlaceholder: '例如 2.5.0',
+ maxVersionHint: '拒绝高于此版本的 Claude Code 客户端请求(semver 格式)。留空则不限制最高版本。'
},
scheduling: {
title: '网关调度设置',
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index fda62b29..99cd247e 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -1127,6 +1127,20 @@
{{ t('admin.settings.claudeCode.minVersionHint') }}
+
+
+
+
+ {{ t('admin.settings.claudeCode.maxVersionHint') }}
+
+
@@ -1967,6 +1981,7 @@ const form = reactive({
ops_metrics_interval_seconds: 60,
// Claude Code version check
min_claude_code_version: '',
+ max_claude_code_version: '',
// 分组隔离
allow_ungrouped_key_scheduling: false
})
@@ -2232,6 +2247,7 @@ async function saveSettings() {
enable_identity_patch: form.enable_identity_patch,
identity_patch_prompt: form.identity_patch_prompt,
min_claude_code_version: form.min_claude_code_version,
+ max_claude_code_version: form.max_claude_code_version,
allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling
}
const updated = await adminAPI.settings.updateSettings(payload)