From 7c60ee3c85d53e003607b0dd3bb395a1ab395be9 Mon Sep 17 00:00:00 2001
From: shaw
Date: Tue, 7 Apr 2026 20:28:14 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20Beta=E7=AD=96=E7=95=A5=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=E6=8C=89=E6=A8=A1=E5=9E=8B=E5=8C=BA=E5=88=86=E5=A4=84?=
=?UTF-8?q?=E7=90=86=EF=BC=88=E6=A8=A1=E5=9E=8B=E7=99=BD=E5=90=8D=E5=8D=95?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/handler/dto/settings.go | 11 +-
backend/internal/service/gateway_service.go | 55 +++++--
backend/internal/service/setting_service.go | 12 ++
backend/internal/service/settings_view.go | 11 +-
frontend/src/api/admin/settings.ts | 3 +
frontend/src/i18n/locales/en.ts | 14 +-
frontend/src/i18n/locales/zh.ts | 14 +-
frontend/src/views/admin/SettingsView.vue | 159 +++++++++++++++++++-
8 files changed, 255 insertions(+), 24 deletions(-)
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index acc1129c..aecbf0c8 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -157,10 +157,13 @@ type RectifierSettings struct {
// BetaPolicyRule Beta 策略规则 DTO
type BetaPolicyRule struct {
- BetaToken string `json:"beta_token"`
- Action string `json:"action"`
- Scope string `json:"scope"`
- ErrorMessage string `json:"error_message,omitempty"`
+ BetaToken string `json:"beta_token"`
+ Action string `json:"action"`
+ Scope string `json:"scope"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ ModelWhitelist []string `json:"model_whitelist,omitempty"`
+ FallbackAction string `json:"fallback_action,omitempty"`
+ FallbackErrorMessage string `json:"fallback_error_message,omitempty"`
}
// BetaPolicySettings Beta 策略配置 DTO
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index f410d69b..4ed78e93 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -3946,7 +3946,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest.
// Always overwrite the cache to prevent stale values from a previous retry with a different account.
if account.Platform == PlatformAnthropic && c != nil {
- policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account)
+ policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account, parsed.Model)
if policy.blockErr != nil {
return nil, policy.blockErr
}
@@ -5603,7 +5603,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
- policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account)
+ policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
effectiveDropSet := mergeDropSets(policyFilterSet)
effectiveDropWithClaudeCodeSet := mergeDropSets(policyFilterSet, claude.BetaClaudeCode)
@@ -5843,7 +5843,7 @@ type betaPolicyResult struct {
}
// evaluateBetaPolicy loads settings once and evaluates all rules against the given request.
-func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account) betaPolicyResult {
+func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account, model string) betaPolicyResult {
if s.settingService == nil {
return betaPolicyResult{}
}
@@ -5858,10 +5858,11 @@ func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader stri
if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
continue
}
- switch rule.Action {
+ effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
+ switch effectiveAction {
case BetaPolicyActionBlock:
if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) {
- msg := rule.ErrorMessage
+ msg := effectiveErrMsg
if msg == "" {
msg = "beta feature " + rule.BetaToken + " is not allowed"
}
@@ -5903,7 +5904,7 @@ const betaPolicyFilterSetKey = "betaPolicyFilterSet"
// In the /v1/messages path, Forward() evaluates the policy first and caches the result;
// buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this
// evaluates on demand (one DB call).
-func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account) map[string]struct{} {
+func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account, model string) map[string]struct{} {
if c != nil {
if v, ok := c.Get(betaPolicyFilterSetKey); ok {
if fs, ok := v.(map[string]struct{}); ok {
@@ -5911,7 +5912,7 @@ func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Cont
}
}
}
- return s.evaluateBetaPolicy(ctx, "", account).filterSet
+ return s.evaluateBetaPolicy(ctx, "", account, model).filterSet
}
// betaPolicyScopeMatches checks whether a rule's scope matches the current account type.
@@ -5930,6 +5931,33 @@ func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool {
}
}
+// matchModelWhitelist checks if a model matches any pattern in the whitelist.
+// Reuses matchModelPattern from group.go which supports exact and wildcard prefix matching.
+func matchModelWhitelist(model string, whitelist []string) bool {
+ for _, pattern := range whitelist {
+ if matchModelPattern(pattern, model) {
+ return true
+ }
+ }
+ return false
+}
+
+// resolveRuleAction determines the effective action and error message for a rule given the request model.
+// When ModelWhitelist is empty, the rule's primary Action/ErrorMessage applies unconditionally.
+// When non-empty, Action applies to matching models; FallbackAction/FallbackErrorMessage applies to others.
+func resolveRuleAction(rule BetaPolicyRule, model string) (action, errorMessage string) {
+ if len(rule.ModelWhitelist) == 0 {
+ return rule.Action, rule.ErrorMessage
+ }
+ if matchModelWhitelist(model, rule.ModelWhitelist) {
+ return rule.Action, rule.ErrorMessage
+ }
+ if rule.FallbackAction != "" {
+ return rule.FallbackAction, rule.FallbackErrorMessage
+ }
+ return BetaPolicyActionPass, "" // default fallback: pass (fail-open)
+}
+
// droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens.
func droppedBetaSet(extra ...string) map[string]struct{} {
m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(extra))
@@ -5976,7 +6004,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest(
modelID string,
) ([]string, error) {
// 1. 对原始 header 中的 beta token 做 block 检查(快速失败)
- policy := s.evaluateBetaPolicy(ctx, betaHeader, account)
+ policy := s.evaluateBetaPolicy(ctx, betaHeader, account, modelID)
if policy.blockErr != nil {
return nil, policy.blockErr
}
@@ -5988,7 +6016,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest(
// 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token,
// 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 →
// 如果不做此检查,block 规则会被绕过。
- if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account); blockErr != nil {
+ if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account, modelID); blockErr != nil {
return nil, blockErr
}
@@ -5997,7 +6025,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest(
// checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。
// 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。
-func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account) *BetaBlockedError {
+func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account, model string) *BetaBlockedError {
if s.settingService == nil || len(tokens) == 0 {
return nil
}
@@ -6009,14 +6037,15 @@ func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, toke
isBedrock := account.IsBedrock()
tokenSet := buildBetaTokenSet(tokens)
for _, rule := range settings.Rules {
- if rule.Action != BetaPolicyActionBlock {
+ effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
+ if effectiveAction != BetaPolicyActionBlock {
continue
}
if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
continue
}
if _, present := tokenSet[rule.BetaToken]; present {
- msg := rule.ErrorMessage
+ msg := effectiveErrMsg
if msg == "" {
msg = "beta feature " + rule.BetaToken + " is not allowed"
}
@@ -8474,7 +8503,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
// Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules
- ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account))
+ ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID))
// OAuth 账号:处理 anthropic-beta header
if tokenType == "oauth" {
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index a85efabd..b7145121 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -1527,6 +1527,18 @@ func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *Be
if !validScopes[rule.Scope] {
return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope)
}
+ // Validate model_whitelist patterns
+ for j, pattern := range rule.ModelWhitelist {
+ trimmed := strings.TrimSpace(pattern)
+ if trimmed == "" {
+ return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j)
+ }
+ settings.Rules[i].ModelWhitelist[j] = trimmed
+ }
+ // Validate fallback_action
+ if rule.FallbackAction != "" && !validActions[rule.FallbackAction] {
+ return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
+ }
}
data, err := json.Marshal(settings)
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 4b64267f..473d7297 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -178,10 +178,13 @@ const (
// BetaPolicyRule 单条 Beta 策略规则
type BetaPolicyRule struct {
- BetaToken string `json:"beta_token"` // beta token 值
- Action string `json:"action"` // "pass" | "filter" | "block"
- Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock"
- ErrorMessage string `json:"error_message,omitempty"` // 自定义错误消息 (action=block 时生效)
+ BetaToken string `json:"beta_token"` // beta token 值
+ Action string `json:"action"` // "pass" | "filter" | "block"
+ Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock"
+ ErrorMessage string `json:"error_message,omitempty"` // 自定义错误消息 (action=block 时生效)
+ ModelWhitelist []string `json:"model_whitelist,omitempty"` // 模型匹配模式列表(为空=对所有模型生效)
+ FallbackAction string `json:"fallback_action,omitempty"` // 未匹配白名单的模型的处理方式
+ FallbackErrorMessage string `json:"fallback_error_message,omitempty"` // 未匹配白名单时的自定义错误消息 (fallback_action=block 时生效)
}
// BetaPolicySettings Beta 策略配置
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 8f9284b7..013f2dfb 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -359,6 +359,9 @@ export interface BetaPolicyRule {
action: 'pass' | 'filter' | 'block'
scope: 'all' | 'oauth' | 'apikey' | 'bedrock'
error_message?: string
+ model_whitelist?: string[]
+ fallback_action?: 'pass' | 'filter' | 'block'
+ fallback_error_message?: string
}
/**
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 30c87d92..fc9297fd 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -4587,7 +4587,19 @@ export default {
errorMessagePlaceholder: 'Custom error message when blocked',
errorMessageHint: 'Leave empty for default message',
saved: 'Beta policy settings saved',
- saveFailed: 'Failed to save beta policy settings'
+ saveFailed: 'Failed to save beta policy settings',
+ modelWhitelist: 'Model Whitelist',
+ modelWhitelistHint: 'Leave empty to apply to all models. Supports exact match and wildcard prefix (e.g., claude-opus-*)',
+ modelPatternPlaceholder: 'e.g., claude-opus-* or claude-opus-4-6',
+ addModelPattern: 'Add model pattern',
+ removePattern: 'Remove',
+ fallbackAction: 'Fallback Action',
+ fallbackActionHint: 'Action for models not matching the whitelist',
+ fallbackErrorMessagePlaceholder: 'Custom error message when non-whitelisted models are blocked',
+ quickPresets: 'Quick Presets',
+ presetOpusOnly: 'Opus only for 1M',
+ presetOpusOnlyDesc: 'Pass for Opus, filter others',
+ commonPatterns: 'Common patterns'
},
saveSettings: 'Save Settings',
saving: 'Saving...',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index d7d920ae..57bfefdc 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -4751,7 +4751,19 @@ export default {
errorMessagePlaceholder: '拦截时返回的自定义错误消息',
errorMessageHint: '留空则使用默认错误消息',
saved: 'Beta 策略设置保存成功',
- saveFailed: '保存 Beta 策略设置失败'
+ saveFailed: '保存 Beta 策略设置失败',
+ modelWhitelist: '模型白名单',
+ modelWhitelistHint: '留空则对所有模型生效。支持精确匹配和通配符前缀(如 claude-opus-*)',
+ modelPatternPlaceholder: '例如: claude-opus-* 或 claude-opus-4-6',
+ addModelPattern: '添加模型规则',
+ removePattern: '移除',
+ fallbackAction: '未匹配模型处理方式',
+ fallbackActionHint: '当请求模型不在白名单中时的处理方式',
+ fallbackErrorMessagePlaceholder: '未匹配模型被拦截时返回的自定义错误消息',
+ quickPresets: '快捷预设',
+ presetOpusOnly: '仅 Opus 允许 1M',
+ presetOpusOnlyDesc: 'Opus 透传,其他模型过滤',
+ commonPatterns: '常用模式'
},
saveSettings: '保存设置',
saving: '保存中...',
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 1839d03c..9ae40aeb 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -630,6 +630,108 @@
{{ t('admin.settings.betaPolicy.errorMessageHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.settings.betaPolicy.modelWhitelistHint') }}
+
+
+
+
+
+
+
+ {{ t('admin.settings.betaPolicy.commonPatterns') }}:
+
+
+
+
+
+
+
+
+
+ {{ t('admin.settings.betaPolicy.fallbackActionHint') }}
+
+
+
+
+
+ {{ t('admin.settings.betaPolicy.errorMessageHint') }}
+
+
+
@@ -2058,6 +2160,9 @@ const betaPolicyForm = reactive({
action: 'pass' | 'filter' | 'block'
scope: 'all' | 'oauth' | 'apikey' | 'bedrock'
error_message?: string
+ model_whitelist?: string[]
+ fallback_action?: 'pass' | 'filter' | 'block'
+ fallback_error_message?: string
}>
})
@@ -2716,10 +2821,48 @@ const betaDisplayNames: Record = {
'context-1m-2025-08-07': 'Context 1M'
}
+// 快捷预设:按 beta_token 定义预设方案
+const betaPresets: Record> = {
+ 'context-1m-2025-08-07': [
+ {
+ label: t('admin.settings.betaPolicy.presetOpusOnly'),
+ description: t('admin.settings.betaPolicy.presetOpusOnlyDesc'),
+ action: 'pass',
+ model_whitelist: ['claude-opus-4-6'],
+ fallback_action: 'filter',
+ },
+ ],
+}
+
+// 常用模型模式(具体 ID + 通配符示例)
+const commonModelPatterns = ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-*', 'claude-sonnet-*']
+
function getBetaDisplayName(token: string): string {
return betaDisplayNames[token] || token
}
+function applyBetaPreset(
+ rule: (typeof betaPolicyForm.rules)[number],
+ preset: { action: 'pass' | 'filter' | 'block'; model_whitelist: string[]; fallback_action: 'pass' | 'filter' | 'block' }
+) {
+ rule.action = preset.action
+ rule.model_whitelist = [...preset.model_whitelist]
+ rule.fallback_action = preset.fallback_action
+}
+
+function addQuickPattern(rule: (typeof betaPolicyForm.rules)[number], pattern: string) {
+ if (!rule.model_whitelist) rule.model_whitelist = []
+ if (!rule.model_whitelist.includes(pattern)) {
+ rule.model_whitelist.push(pattern)
+ }
+}
+
async function loadBetaPolicySettings() {
betaPolicyLoading.value = true
try {
@@ -2735,8 +2878,22 @@ async function loadBetaPolicySettings() {
async function saveBetaPolicySettings() {
betaPolicySaving.value = true
try {
+ // Clean up empty patterns before saving
+ const cleanedRules = betaPolicyForm.rules.map(rule => {
+ const whitelist = rule.model_whitelist?.filter(p => p.trim() !== '')
+ const hasWhitelist = whitelist && whitelist.length > 0
+ return {
+ beta_token: rule.beta_token,
+ action: rule.action,
+ scope: rule.scope,
+ error_message: rule.error_message,
+ model_whitelist: hasWhitelist ? whitelist : undefined,
+ fallback_action: hasWhitelist ? (rule.fallback_action || 'pass') : undefined,
+ fallback_error_message: hasWhitelist && rule.fallback_action === 'block' ? rule.fallback_error_message : undefined,
+ }
+ })
const updated = await adminAPI.settings.updateBetaPolicySettings({
- rules: betaPolicyForm.rules
+ rules: cleanedRules
})
betaPolicyForm.rules = updated.rules
appStore.showSuccess(t('admin.settings.betaPolicy.saved'))