Skip to content

Commit 00a0a12

Browse files
committed
feat: Anthropic平台可配置 anthropic-beta 策略
1 parent ac6bde7 commit 00a0a12

14 files changed

Lines changed: 588 additions & 29 deletions

File tree

backend/internal/handler/admin/setting_handler.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,61 @@ func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) {
14051405
})
14061406
}
14071407

1408+
// GetBetaPolicySettings 获取 Beta 策略配置
1409+
// GET /api/v1/admin/settings/beta-policy
1410+
func (h *SettingHandler) GetBetaPolicySettings(c *gin.Context) {
1411+
settings, err := h.settingService.GetBetaPolicySettings(c.Request.Context())
1412+
if err != nil {
1413+
response.ErrorFrom(c, err)
1414+
return
1415+
}
1416+
1417+
rules := make([]dto.BetaPolicyRule, len(settings.Rules))
1418+
for i, r := range settings.Rules {
1419+
rules[i] = dto.BetaPolicyRule(r)
1420+
}
1421+
response.Success(c, dto.BetaPolicySettings{Rules: rules})
1422+
}
1423+
1424+
// UpdateBetaPolicySettingsRequest 更新 Beta 策略配置请求
1425+
type UpdateBetaPolicySettingsRequest struct {
1426+
Rules []dto.BetaPolicyRule `json:"rules"`
1427+
}
1428+
1429+
// UpdateBetaPolicySettings 更新 Beta 策略配置
1430+
// PUT /api/v1/admin/settings/beta-policy
1431+
func (h *SettingHandler) UpdateBetaPolicySettings(c *gin.Context) {
1432+
var req UpdateBetaPolicySettingsRequest
1433+
if err := c.ShouldBindJSON(&req); err != nil {
1434+
response.BadRequest(c, "Invalid request: "+err.Error())
1435+
return
1436+
}
1437+
1438+
rules := make([]service.BetaPolicyRule, len(req.Rules))
1439+
for i, r := range req.Rules {
1440+
rules[i] = service.BetaPolicyRule(r)
1441+
}
1442+
1443+
settings := &service.BetaPolicySettings{Rules: rules}
1444+
if err := h.settingService.SetBetaPolicySettings(c.Request.Context(), settings); err != nil {
1445+
response.BadRequest(c, err.Error())
1446+
return
1447+
}
1448+
1449+
// Re-fetch to return updated settings
1450+
updated, err := h.settingService.GetBetaPolicySettings(c.Request.Context())
1451+
if err != nil {
1452+
response.ErrorFrom(c, err)
1453+
return
1454+
}
1455+
1456+
outRules := make([]dto.BetaPolicyRule, len(updated.Rules))
1457+
for i, r := range updated.Rules {
1458+
outRules[i] = dto.BetaPolicyRule(r)
1459+
}
1460+
response.Success(c, dto.BetaPolicySettings{Rules: outRules})
1461+
}
1462+
14081463
// UpdateStreamTimeoutSettingsRequest 更新流超时配置请求
14091464
type UpdateStreamTimeoutSettingsRequest struct {
14101465
Enabled bool `json:"enabled"`

backend/internal/handler/dto/settings.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ type RectifierSettings struct {
168168
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
169169
}
170170

171+
// BetaPolicyRule Beta 策略规则 DTO
172+
type BetaPolicyRule struct {
173+
BetaToken string `json:"beta_token"`
174+
Action string `json:"action"`
175+
Scope string `json:"scope"`
176+
ErrorMessage string `json:"error_message,omitempty"`
177+
}
178+
179+
// BetaPolicySettings Beta 策略配置 DTO
180+
type BetaPolicySettings struct {
181+
Rules []BetaPolicyRule `json:"rules"`
182+
}
183+
171184
// ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
172185
// Returns empty slice on empty/invalid input.
173186
func ParseCustomMenuItems(raw string) []CustomMenuItem {

backend/internal/handler/gateway_handler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,13 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
652652
accountReleaseFunc()
653653
}
654654
if err != nil {
655+
// Beta policy block: return 400 immediately, no failover
656+
var betaBlockedErr *service.BetaBlockedError
657+
if errors.As(err, &betaBlockedErr) {
658+
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", betaBlockedErr.Message)
659+
return
660+
}
661+
655662
var promptTooLongErr *service.PromptTooLongError
656663
if errors.As(err, &promptTooLongErr) {
657664
reqLog.Warn("gateway.prompt_too_long_from_antigravity",

backend/internal/pkg/claude/constants.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const (
1616

1717
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
1818
// 这些 token 是客户端特有的,不应透传给上游 API。
19-
var DroppedBetas = []string{BetaFastMode}
19+
var DroppedBetas = []string{}
2020

2121
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
2222
const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming

backend/internal/server/routes/admin.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
398398
// 请求整流器配置
399399
adminSettings.GET("/rectifier", h.Admin.Setting.GetRectifierSettings)
400400
adminSettings.PUT("/rectifier", h.Admin.Setting.UpdateRectifierSettings)
401+
// Beta 策略配置
402+
adminSettings.GET("/beta-policy", h.Admin.Setting.GetBetaPolicySettings)
403+
adminSettings.PUT("/beta-policy", h.Admin.Setting.UpdateBetaPolicySettings)
401404
// Sora S3 存储配置
402405
adminSettings.GET("/sora-s3", h.Admin.Setting.GetSoraS3Settings)
403406
adminSettings.PUT("/sora-s3", h.Admin.Setting.UpdateSoraS3Settings)

backend/internal/service/domain_constants.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ const (
182182
// SettingKeyRectifierSettings stores JSON config for rectifier settings (thinking signature + budget).
183183
SettingKeyRectifierSettings = "rectifier_settings"
184184

185+
// =========================
186+
// Beta Policy Settings
187+
// =========================
188+
189+
// SettingKeyBetaPolicySettings stores JSON config for beta policy rules.
190+
SettingKeyBetaPolicySettings = "beta_policy_settings"
191+
185192
// =========================
186193
// Sora S3 存储配置
187194
// =========================

backend/internal/service/gateway_beta_test.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,10 @@ func TestStripBetaTokens(t *testing.T) {
8686
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
8787
},
8888
{
89-
name: "DroppedBetas removes fast-mode only",
89+
name: "DroppedBetas is empty (filtering moved to configurable beta policy)",
9090
header: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
9191
tokens: claude.DroppedBetas,
92-
want: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14",
92+
want: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
9393
},
9494
}
9595

@@ -114,25 +114,23 @@ func TestMergeAnthropicBetaDropping_Context1M(t *testing.T) {
114114
func TestMergeAnthropicBetaDropping_DroppedBetas(t *testing.T) {
115115
required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
116116
incoming := "context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta,oauth-2025-04-20"
117+
// DroppedBetas is now empty — filtering moved to configurable beta policy.
118+
// Without a policy filter set, nothing gets dropped from the static set.
117119
drop := droppedBetaSet()
118120

119121
got := mergeAnthropicBetaDropping(required, incoming, drop)
120-
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07,foo-beta", got)
122+
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta", got)
121123
require.Contains(t, got, "context-1m-2025-08-07")
122-
require.NotContains(t, got, "fast-mode-2026-02-01")
124+
require.Contains(t, got, "fast-mode-2026-02-01")
123125
}
124126

125127
func TestDroppedBetaSet(t *testing.T) {
126-
// Base set contains DroppedBetas
128+
// Base set contains DroppedBetas (now empty — filtering moved to configurable beta policy)
127129
base := droppedBetaSet()
128-
require.NotContains(t, base, claude.BetaContext1M)
129-
require.Contains(t, base, claude.BetaFastMode)
130130
require.Len(t, base, len(claude.DroppedBetas))
131131

132132
// With extra tokens
133133
extended := droppedBetaSet(claude.BetaClaudeCode)
134-
require.NotContains(t, extended, claude.BetaContext1M)
135-
require.Contains(t, extended, claude.BetaFastMode)
136134
require.Contains(t, extended, claude.BetaClaudeCode)
137135
require.Len(t, extended, len(claude.DroppedBetas)+1)
138136
}

backend/internal/service/gateway_service.go

Lines changed: 145 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
53385460
func 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

Comments
 (0)