@@ -3948,6 +3948,20 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
39483948 return s .forwardAnthropicAPIKeyPassthrough (ctx , c , account , passthroughBody , passthroughModel , parsed .Stream , startTime )
39493949 }
39503950
3951+ // Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest.
3952+ // Always overwrite the cache to prevent stale values from a previous retry with a different account.
3953+ if account .Platform == PlatformAnthropic && c != nil {
3954+ policy := s .evaluateBetaPolicy (ctx , c .GetHeader ("anthropic-beta" ), account )
3955+ if policy .blockErr != nil {
3956+ return nil , policy .blockErr
3957+ }
3958+ filterSet := policy .filterSet
3959+ if filterSet == nil {
3960+ filterSet = map [string ]struct {}{}
3961+ }
3962+ c .Set (betaPolicyFilterSetKey , filterSet )
3963+ }
3964+
39513965 body := parsed .Body
39523966 reqModel := parsed .Model
39533967 reqStream := parsed .Stream
@@ -5133,6 +5147,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
51335147 applyClaudeOAuthHeaderDefaults (req , reqStream )
51345148 }
51355149
5150+ // Build effective drop set: merge static defaults with dynamic beta policy filter rules
5151+ policyFilterSet := s .getBetaPolicyFilterSet (ctx , c , account )
5152+ effectiveDropSet := mergeDropSets (policyFilterSet )
5153+ effectiveDropWithClaudeCodeSet := mergeDropSets (policyFilterSet , claude .BetaClaudeCode )
5154+
51365155 // 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta)
51375156 if tokenType == "oauth" {
51385157 if mimicClaudeCode {
@@ -5146,17 +5165,22 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
51465165 // messages requests typically use only oauth + interleaved-thinking.
51475166 // Also drop claude-code beta if a downstream client added it.
51485167 requiredBetas := []string {claude .BetaOAuth , claude .BetaInterleavedThinking }
5149- req .Header .Set ("anthropic-beta" , mergeAnthropicBetaDropping (requiredBetas , incomingBeta , droppedBetasWithClaudeCodeSet ))
5168+ req .Header .Set ("anthropic-beta" , mergeAnthropicBetaDropping (requiredBetas , incomingBeta , effectiveDropWithClaudeCodeSet ))
51505169 } else {
51515170 // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
51525171 clientBetaHeader := req .Header .Get ("anthropic-beta" )
5153- req .Header .Set ("anthropic-beta" , stripBetaTokensWithSet (s .getBetaHeader (modelID , clientBetaHeader ), defaultDroppedBetasSet ))
5172+ req .Header .Set ("anthropic-beta" , stripBetaTokensWithSet (s .getBetaHeader (modelID , clientBetaHeader ), effectiveDropSet ))
51545173 }
5155- } else if s .cfg != nil && s .cfg .Gateway .InjectBetaForAPIKey && req .Header .Get ("anthropic-beta" ) == "" {
5156- // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
5157- if requestNeedsBetaFeatures (body ) {
5158- if beta := defaultAPIKeyBetaHeader (body ); beta != "" {
5159- req .Header .Set ("anthropic-beta" , beta )
5174+ } else {
5175+ // API-key accounts: apply beta policy filter to strip controlled tokens
5176+ if existingBeta := req .Header .Get ("anthropic-beta" ); existingBeta != "" {
5177+ req .Header .Set ("anthropic-beta" , stripBetaTokensWithSet (existingBeta , effectiveDropSet ))
5178+ } else if s .cfg != nil && s .cfg .Gateway .InjectBetaForAPIKey {
5179+ // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
5180+ if requestNeedsBetaFeatures (body ) {
5181+ if beta := defaultAPIKeyBetaHeader (body ); beta != "" {
5182+ req .Header .Set ("anthropic-beta" , beta )
5183+ }
51605184 }
51615185 }
51625186 }
@@ -5334,6 +5358,104 @@ func stripBetaTokensWithSet(header string, drop map[string]struct{}) string {
53345358 return strings .Join (out , "," )
53355359}
53365360
5361+ // BetaBlockedError indicates a request was blocked by a beta policy rule.
5362+ type BetaBlockedError struct {
5363+ Message string
5364+ }
5365+
5366+ func (e * BetaBlockedError ) Error () string { return e .Message }
5367+
5368+ // betaPolicyResult holds the evaluated result of beta policy rules for a single request.
5369+ type betaPolicyResult struct {
5370+ blockErr * BetaBlockedError // non-nil if a block rule matched
5371+ filterSet map [string ]struct {} // tokens to filter (may be nil)
5372+ }
5373+
5374+ // evaluateBetaPolicy loads settings once and evaluates all rules against the given request.
5375+ func (s * GatewayService ) evaluateBetaPolicy (ctx context.Context , betaHeader string , account * Account ) betaPolicyResult {
5376+ if s .settingService == nil {
5377+ return betaPolicyResult {}
5378+ }
5379+ settings , err := s .settingService .GetBetaPolicySettings (ctx )
5380+ if err != nil || settings == nil {
5381+ return betaPolicyResult {}
5382+ }
5383+ isOAuth := account .IsOAuth ()
5384+ var result betaPolicyResult
5385+ for _ , rule := range settings .Rules {
5386+ if ! betaPolicyScopeMatches (rule .Scope , isOAuth ) {
5387+ continue
5388+ }
5389+ switch rule .Action {
5390+ case BetaPolicyActionBlock :
5391+ if result .blockErr == nil && betaHeader != "" && containsBetaToken (betaHeader , rule .BetaToken ) {
5392+ msg := rule .ErrorMessage
5393+ if msg == "" {
5394+ msg = "beta feature " + rule .BetaToken + " is not allowed"
5395+ }
5396+ result .blockErr = & BetaBlockedError {Message : msg }
5397+ }
5398+ case BetaPolicyActionFilter :
5399+ if result .filterSet == nil {
5400+ result .filterSet = make (map [string ]struct {})
5401+ }
5402+ result .filterSet [rule .BetaToken ] = struct {}{}
5403+ }
5404+ }
5405+ return result
5406+ }
5407+
5408+ // mergeDropSets merges the static defaultDroppedBetasSet with dynamic policy filter tokens.
5409+ // Returns defaultDroppedBetasSet directly when policySet is empty (zero allocation).
5410+ func mergeDropSets (policySet map [string ]struct {}, extra ... string ) map [string ]struct {} {
5411+ if len (policySet ) == 0 && len (extra ) == 0 {
5412+ return defaultDroppedBetasSet
5413+ }
5414+ m := make (map [string ]struct {}, len (defaultDroppedBetasSet )+ len (policySet )+ len (extra ))
5415+ for t := range defaultDroppedBetasSet {
5416+ m [t ] = struct {}{}
5417+ }
5418+ for t := range policySet {
5419+ m [t ] = struct {}{}
5420+ }
5421+ for _ , t := range extra {
5422+ m [t ] = struct {}{}
5423+ }
5424+ return m
5425+ }
5426+
5427+ // betaPolicyFilterSetKey is the gin.Context key for caching the policy filter set within a request.
5428+ const betaPolicyFilterSetKey = "betaPolicyFilterSet"
5429+
5430+ // getBetaPolicyFilterSet returns the beta policy filter set, using the gin context cache if available.
5431+ // In the /v1/messages path, Forward() evaluates the policy first and caches the result;
5432+ // buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this
5433+ // evaluates on demand (one DB call).
5434+ func (s * GatewayService ) getBetaPolicyFilterSet (ctx context.Context , c * gin.Context , account * Account ) map [string ]struct {} {
5435+ if c != nil {
5436+ if v , ok := c .Get (betaPolicyFilterSetKey ); ok {
5437+ if fs , ok := v .(map [string ]struct {}); ok {
5438+ return fs
5439+ }
5440+ }
5441+ }
5442+ return s .evaluateBetaPolicy (ctx , "" , account ).filterSet
5443+ }
5444+
5445+ // betaPolicyScopeMatches checks whether a rule's scope matches the current account type.
5446+ func betaPolicyScopeMatches (scope string , isOAuth bool ) bool {
5447+ switch scope {
5448+ case BetaPolicyScopeAll :
5449+ return true
5450+ case BetaPolicyScopeOAuth :
5451+ return isOAuth
5452+ case BetaPolicyScopeAPIKey :
5453+ return ! isOAuth
5454+ default :
5455+ return true // unknown scope → match all (fail-open)
5456+ }
5457+ }
5458+
53375459// droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens.
53385460func droppedBetaSet (extra ... string ) map [string ]struct {} {
53395461 m := make (map [string ]struct {}, len (defaultDroppedBetasSet )+ len (extra ))
@@ -5370,10 +5492,7 @@ func buildBetaTokenSet(tokens []string) map[string]struct{} {
53705492 return m
53715493}
53725494
5373- var (
5374- defaultDroppedBetasSet = buildBetaTokenSet (claude .DroppedBetas )
5375- droppedBetasWithClaudeCodeSet = droppedBetaSet (claude .BetaClaudeCode )
5376- )
5495+ var defaultDroppedBetasSet = buildBetaTokenSet (claude .DroppedBetas )
53775496
53785497// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
53795498// This mirrors opencode-anthropic-auth behavior: do not trust downstream
@@ -7311,15 +7430,17 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
73117430 applyClaudeOAuthHeaderDefaults (req , false )
73127431 }
73137432
7433+ // Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules
7434+ ctEffectiveDropSet := mergeDropSets (s .getBetaPolicyFilterSet (ctx , c , account ))
7435+
73147436 // OAuth 账号:处理 anthropic-beta header
73157437 if tokenType == "oauth" {
73167438 if mimicClaudeCode {
73177439 applyClaudeCodeMimicHeaders (req , false )
73187440
73197441 incomingBeta := req .Header .Get ("anthropic-beta" )
73207442 requiredBetas := []string {claude .BetaClaudeCode , claude .BetaOAuth , claude .BetaInterleavedThinking , claude .BetaTokenCounting }
7321- drop := droppedBetaSet ()
7322- req .Header .Set ("anthropic-beta" , mergeAnthropicBetaDropping (requiredBetas , incomingBeta , drop ))
7443+ req .Header .Set ("anthropic-beta" , mergeAnthropicBetaDropping (requiredBetas , incomingBeta , ctEffectiveDropSet ))
73237444 } else {
73247445 clientBetaHeader := req .Header .Get ("anthropic-beta" )
73257446 if clientBetaHeader == "" {
@@ -7329,14 +7450,19 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
73297450 if ! strings .Contains (beta , claude .BetaTokenCounting ) {
73307451 beta = beta + "," + claude .BetaTokenCounting
73317452 }
7332- req .Header .Set ("anthropic-beta" , stripBetaTokensWithSet (beta , defaultDroppedBetasSet ))
7453+ req .Header .Set ("anthropic-beta" , stripBetaTokensWithSet (beta , ctEffectiveDropSet ))
73337454 }
73347455 }
7335- } else if s .cfg != nil && s .cfg .Gateway .InjectBetaForAPIKey && req .Header .Get ("anthropic-beta" ) == "" {
7336- // API-key:与 messages 同步的按需 beta 注入(默认关闭)
7337- if requestNeedsBetaFeatures (body ) {
7338- if beta := defaultAPIKeyBetaHeader (body ); beta != "" {
7339- req .Header .Set ("anthropic-beta" , beta )
7456+ } else {
7457+ // API-key accounts: apply beta policy filter to strip controlled tokens
7458+ if existingBeta := req .Header .Get ("anthropic-beta" ); existingBeta != "" {
7459+ req .Header .Set ("anthropic-beta" , stripBetaTokensWithSet (existingBeta , ctEffectiveDropSet ))
7460+ } else if s .cfg != nil && s .cfg .Gateway .InjectBetaForAPIKey {
7461+ // API-key:与 messages 同步的按需 beta 注入(默认关闭)
7462+ if requestNeedsBetaFeatures (body ) {
7463+ if beta := defaultAPIKeyBetaHeader (body ); beta != "" {
7464+ req .Header .Set ("anthropic-beta" , beta )
7465+ }
73407466 }
73417467 }
73427468 }
0 commit comments