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.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'))