diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index f89ba2ed..8330868d 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1405,6 +1405,61 @@ func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) { }) } +// GetBetaPolicySettings 获取 Beta 策略配置 +// GET /api/v1/admin/settings/beta-policy +func (h *SettingHandler) GetBetaPolicySettings(c *gin.Context) { + settings, err := h.settingService.GetBetaPolicySettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + rules := make([]dto.BetaPolicyRule, len(settings.Rules)) + for i, r := range settings.Rules { + rules[i] = dto.BetaPolicyRule(r) + } + response.Success(c, dto.BetaPolicySettings{Rules: rules}) +} + +// UpdateBetaPolicySettingsRequest 更新 Beta 策略配置请求 +type UpdateBetaPolicySettingsRequest struct { + Rules []dto.BetaPolicyRule `json:"rules"` +} + +// UpdateBetaPolicySettings 更新 Beta 策略配置 +// PUT /api/v1/admin/settings/beta-policy +func (h *SettingHandler) UpdateBetaPolicySettings(c *gin.Context) { + var req UpdateBetaPolicySettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + rules := make([]service.BetaPolicyRule, len(req.Rules)) + for i, r := range req.Rules { + rules[i] = service.BetaPolicyRule(r) + } + + settings := &service.BetaPolicySettings{Rules: rules} + if err := h.settingService.SetBetaPolicySettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // Re-fetch to return updated settings + updated, err := h.settingService.GetBetaPolicySettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + outRules := make([]dto.BetaPolicyRule, len(updated.Rules)) + for i, r := range updated.Rules { + outRules[i] = dto.BetaPolicyRule(r) + } + response.Success(c, dto.BetaPolicySettings{Rules: outRules}) +} + // UpdateStreamTimeoutSettingsRequest 更新流超时配置请求 type UpdateStreamTimeoutSettingsRequest struct { Enabled bool `json:"enabled"` diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index f1889dd7..8a1bba5d 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -168,6 +168,19 @@ type RectifierSettings struct { ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"` } +// 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"` +} + +// BetaPolicySettings Beta 策略配置 DTO +type BetaPolicySettings struct { + Rules []BetaPolicyRule `json:"rules"` +} + // ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem. // Returns empty slice on empty/invalid input. func ParseCustomMenuItems(raw string) []CustomMenuItem { diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 88502f77..4441cf07 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -652,6 +652,13 @@ func (h *GatewayHandler) Messages(c *gin.Context) { accountReleaseFunc() } if err != nil { + // Beta policy block: return 400 immediately, no failover + var betaBlockedErr *service.BetaBlockedError + if errors.As(err, &betaBlockedErr) { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", betaBlockedErr.Message) + return + } + var promptTooLongErr *service.PromptTooLongError if errors.As(err, &promptTooLongErr) { reqLog.Warn("gateway.prompt_too_long_from_antigravity", diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 697663a6..dfca252f 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -16,7 +16,7 @@ const ( // DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。 // 这些 token 是客户端特有的,不应透传给上游 API。 -var DroppedBetas = []string{BetaFastMode} +var DroppedBetas = []string{} // DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 5f4a0784..a69f1595 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -398,6 +398,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // 请求整流器配置 adminSettings.GET("/rectifier", h.Admin.Setting.GetRectifierSettings) adminSettings.PUT("/rectifier", h.Admin.Setting.UpdateRectifierSettings) + // Beta 策略配置 + adminSettings.GET("/beta-policy", h.Admin.Setting.GetBetaPolicySettings) + adminSettings.PUT("/beta-policy", h.Admin.Setting.UpdateBetaPolicySettings) // Sora S3 存储配置 adminSettings.GET("/sora-s3", h.Admin.Setting.GetSoraS3Settings) adminSettings.PUT("/sora-s3", h.Admin.Setting.UpdateSoraS3Settings) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index d2d8aa3f..304c09f4 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -182,6 +182,13 @@ const ( // SettingKeyRectifierSettings stores JSON config for rectifier settings (thinking signature + budget). SettingKeyRectifierSettings = "rectifier_settings" + // ========================= + // Beta Policy Settings + // ========================= + + // SettingKeyBetaPolicySettings stores JSON config for beta policy rules. + SettingKeyBetaPolicySettings = "beta_policy_settings" + // ========================= // Sora S3 存储配置 // ========================= diff --git a/backend/internal/service/gateway_beta_test.go b/backend/internal/service/gateway_beta_test.go index a389ecf1..ecaffe21 100644 --- a/backend/internal/service/gateway_beta_test.go +++ b/backend/internal/service/gateway_beta_test.go @@ -86,10 +86,10 @@ func TestStripBetaTokens(t *testing.T) { want: "oauth-2025-04-20,interleaved-thinking-2025-05-14", }, { - name: "DroppedBetas removes fast-mode only", + name: "DroppedBetas is empty (filtering moved to configurable beta policy)", header: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14", tokens: claude.DroppedBetas, - want: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14", + want: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14", }, } @@ -114,25 +114,23 @@ func TestMergeAnthropicBetaDropping_Context1M(t *testing.T) { func TestMergeAnthropicBetaDropping_DroppedBetas(t *testing.T) { required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"} incoming := "context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta,oauth-2025-04-20" + // DroppedBetas is now empty — filtering moved to configurable beta policy. + // Without a policy filter set, nothing gets dropped from the static set. drop := droppedBetaSet() got := mergeAnthropicBetaDropping(required, incoming, drop) - require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07,foo-beta", got) + 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) require.Contains(t, got, "context-1m-2025-08-07") - require.NotContains(t, got, "fast-mode-2026-02-01") + require.Contains(t, got, "fast-mode-2026-02-01") } func TestDroppedBetaSet(t *testing.T) { - // Base set contains DroppedBetas + // Base set contains DroppedBetas (now empty — filtering moved to configurable beta policy) base := droppedBetaSet() - require.NotContains(t, base, claude.BetaContext1M) - require.Contains(t, base, claude.BetaFastMode) require.Len(t, base, len(claude.DroppedBetas)) // With extra tokens extended := droppedBetaSet(claude.BetaClaudeCode) - require.NotContains(t, extended, claude.BetaContext1M) - require.Contains(t, extended, claude.BetaFastMode) require.Contains(t, extended, claude.BetaClaudeCode) require.Len(t, extended, len(claude.DroppedBetas)+1) } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 8be37753..080de063 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3948,6 +3948,20 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A return s.forwardAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody, passthroughModel, parsed.Stream, startTime) } + // 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) + if policy.blockErr != nil { + return nil, policy.blockErr + } + filterSet := policy.filterSet + if filterSet == nil { + filterSet = map[string]struct{}{} + } + c.Set(betaPolicyFilterSetKey, filterSet) + } + body := parsed.Body reqModel := parsed.Model reqStream := parsed.Stream @@ -5133,6 +5147,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex applyClaudeOAuthHeaderDefaults(req, reqStream) } + // Build effective drop set: merge static defaults with dynamic beta policy filter rules + policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account) + effectiveDropSet := mergeDropSets(policyFilterSet) + effectiveDropWithClaudeCodeSet := mergeDropSets(policyFilterSet, claude.BetaClaudeCode) + // 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta) if tokenType == "oauth" { if mimicClaudeCode { @@ -5146,17 +5165,22 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // messages requests typically use only oauth + interleaved-thinking. // Also drop claude-code beta if a downstream client added it. requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} - req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, droppedBetasWithClaudeCodeSet)) + req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet)) } else { // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta clientBetaHeader := req.Header.Get("anthropic-beta") - req.Header.Set("anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), defaultDroppedBetasSet)) + req.Header.Set("anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet)) } - } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" { - // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭) - if requestNeedsBetaFeatures(body) { - if beta := defaultAPIKeyBetaHeader(body); beta != "" { - req.Header.Set("anthropic-beta", beta) + } else { + // API-key accounts: apply beta policy filter to strip controlled tokens + if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" { + req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet)) + } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { + // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭) + if requestNeedsBetaFeatures(body) { + if beta := defaultAPIKeyBetaHeader(body); beta != "" { + req.Header.Set("anthropic-beta", beta) + } } } } @@ -5334,6 +5358,104 @@ func stripBetaTokensWithSet(header string, drop map[string]struct{}) string { return strings.Join(out, ",") } +// BetaBlockedError indicates a request was blocked by a beta policy rule. +type BetaBlockedError struct { + Message string +} + +func (e *BetaBlockedError) Error() string { return e.Message } + +// betaPolicyResult holds the evaluated result of beta policy rules for a single request. +type betaPolicyResult struct { + blockErr *BetaBlockedError // non-nil if a block rule matched + filterSet map[string]struct{} // tokens to filter (may be nil) +} + +// evaluateBetaPolicy loads settings once and evaluates all rules against the given request. +func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account) betaPolicyResult { + if s.settingService == nil { + return betaPolicyResult{} + } + settings, err := s.settingService.GetBetaPolicySettings(ctx) + if err != nil || settings == nil { + return betaPolicyResult{} + } + isOAuth := account.IsOAuth() + var result betaPolicyResult + for _, rule := range settings.Rules { + if !betaPolicyScopeMatches(rule.Scope, isOAuth) { + continue + } + switch rule.Action { + case BetaPolicyActionBlock: + if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) { + msg := rule.ErrorMessage + if msg == "" { + msg = "beta feature " + rule.BetaToken + " is not allowed" + } + result.blockErr = &BetaBlockedError{Message: msg} + } + case BetaPolicyActionFilter: + if result.filterSet == nil { + result.filterSet = make(map[string]struct{}) + } + result.filterSet[rule.BetaToken] = struct{}{} + } + } + return result +} + +// mergeDropSets merges the static defaultDroppedBetasSet with dynamic policy filter tokens. +// Returns defaultDroppedBetasSet directly when policySet is empty (zero allocation). +func mergeDropSets(policySet map[string]struct{}, extra ...string) map[string]struct{} { + if len(policySet) == 0 && len(extra) == 0 { + return defaultDroppedBetasSet + } + m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(policySet)+len(extra)) + for t := range defaultDroppedBetasSet { + m[t] = struct{}{} + } + for t := range policySet { + m[t] = struct{}{} + } + for _, t := range extra { + m[t] = struct{}{} + } + return m +} + +// betaPolicyFilterSetKey is the gin.Context key for caching the policy filter set within a request. +const betaPolicyFilterSetKey = "betaPolicyFilterSet" + +// getBetaPolicyFilterSet returns the beta policy filter set, using the gin context cache if available. +// 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{} { + if c != nil { + if v, ok := c.Get(betaPolicyFilterSetKey); ok { + if fs, ok := v.(map[string]struct{}); ok { + return fs + } + } + } + return s.evaluateBetaPolicy(ctx, "", account).filterSet +} + +// betaPolicyScopeMatches checks whether a rule's scope matches the current account type. +func betaPolicyScopeMatches(scope string, isOAuth bool) bool { + switch scope { + case BetaPolicyScopeAll: + return true + case BetaPolicyScopeOAuth: + return isOAuth + case BetaPolicyScopeAPIKey: + return !isOAuth + default: + return true // unknown scope → match all (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)) @@ -5370,10 +5492,7 @@ func buildBetaTokenSet(tokens []string) map[string]struct{} { return m } -var ( - defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas) - droppedBetasWithClaudeCodeSet = droppedBetaSet(claude.BetaClaudeCode) -) +var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas) // applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers. // This mirrors opencode-anthropic-auth behavior: do not trust downstream @@ -7311,6 +7430,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con applyClaudeOAuthHeaderDefaults(req, false) } + // Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules + ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account)) + // OAuth 账号:处理 anthropic-beta header if tokenType == "oauth" { if mimicClaudeCode { @@ -7318,8 +7440,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con incomingBeta := req.Header.Get("anthropic-beta") requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting} - drop := droppedBetaSet() - req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop)) + req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet)) } else { clientBetaHeader := req.Header.Get("anthropic-beta") if clientBetaHeader == "" { @@ -7329,14 +7450,19 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if !strings.Contains(beta, claude.BetaTokenCounting) { beta = beta + "," + claude.BetaTokenCounting } - req.Header.Set("anthropic-beta", stripBetaTokensWithSet(beta, defaultDroppedBetasSet)) + req.Header.Set("anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet)) } } - } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" { - // API-key:与 messages 同步的按需 beta 注入(默认关闭) - if requestNeedsBetaFeatures(body) { - if beta := defaultAPIKeyBetaHeader(body); beta != "" { - req.Header.Set("anthropic-beta", beta) + } else { + // API-key accounts: apply beta policy filter to strip controlled tokens + if existingBeta := req.Header.Get("anthropic-beta"); existingBeta != "" { + req.Header.Set("anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet)) + } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey { + // API-key:与 messages 同步的按需 beta 注入(默认关闭) + if requestNeedsBetaFeatures(body) { + if beta := defaultAPIKeyBetaHeader(body); beta != "" { + req.Header.Set("anthropic-beta", beta) + } } } } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 3a11f51c..b77867de 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1247,6 +1247,60 @@ func (s *SettingService) IsBudgetRectifierEnabled(ctx context.Context) bool { return settings.Enabled && settings.ThinkingBudgetEnabled } +// GetBetaPolicySettings 获取 Beta 策略配置 +func (s *SettingService) GetBetaPolicySettings(ctx context.Context) (*BetaPolicySettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyBetaPolicySettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultBetaPolicySettings(), nil + } + return nil, fmt.Errorf("get beta policy settings: %w", err) + } + if value == "" { + return DefaultBetaPolicySettings(), nil + } + + var settings BetaPolicySettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultBetaPolicySettings(), nil + } + + return &settings, nil +} + +// SetBetaPolicySettings 设置 Beta 策略配置 +func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *BetaPolicySettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + validActions := map[string]bool{ + BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, + } + validScopes := map[string]bool{ + BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, + } + + for i, rule := range settings.Rules { + if rule.BetaToken == "" { + return fmt.Errorf("rule[%d]: beta_token cannot be empty", i) + } + if !validActions[rule.Action] { + return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action) + } + if !validScopes[rule.Scope] { + return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) + } + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal beta policy settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyBetaPolicySettings, string(data)) +} + // SetStreamTimeoutSettings 设置流超时处理配置 func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error { if settings == nil { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index d6927989..8734e28a 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -191,3 +191,45 @@ func DefaultRectifierSettings() *RectifierSettings { ThinkingBudgetEnabled: true, } } + +// Beta Policy 策略常量 +const ( + BetaPolicyActionPass = "pass" // 透传,不做任何处理 + BetaPolicyActionFilter = "filter" // 过滤,从 beta header 中移除该 token + BetaPolicyActionBlock = "block" // 拦截,直接返回错误 + + BetaPolicyScopeAll = "all" // 所有账号类型 + BetaPolicyScopeOAuth = "oauth" // 仅 OAuth 账号 + BetaPolicyScopeAPIKey = "apikey" // 仅 API Key 账号 +) + +// 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" + ErrorMessage string `json:"error_message,omitempty"` // 自定义错误消息 (action=block 时生效) +} + +// BetaPolicySettings Beta 策略配置 +type BetaPolicySettings struct { + Rules []BetaPolicyRule `json:"rules"` +} + +// DefaultBetaPolicySettings 返回默认的 Beta 策略配置 +func DefaultBetaPolicySettings() *BetaPolicySettings { + return &BetaPolicySettings{ + Rules: []BetaPolicyRule{ + { + BetaToken: "fast-mode-2026-02-01", + Action: BetaPolicyActionFilter, + Scope: BetaPolicyScopeAll, + }, + { + BetaToken: "context-1m-2025-08-07", + Action: BetaPolicyActionFilter, + Scope: BetaPolicyScopeAll, + }, + }, + } +} diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 15233b31..2b156ea1 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -308,6 +308,49 @@ export async function updateRectifierSettings( return data } +// ==================== Beta Policy Settings ==================== + +/** + * Beta policy rule interface + */ +export interface BetaPolicyRule { + beta_token: string + action: 'pass' | 'filter' | 'block' + scope: 'all' | 'oauth' | 'apikey' + error_message?: string +} + +/** + * Beta policy settings interface + */ +export interface BetaPolicySettings { + rules: BetaPolicyRule[] +} + +/** + * Get beta policy settings + * @returns Beta policy settings + */ +export async function getBetaPolicySettings(): Promise { + const { data } = await apiClient.get('/admin/settings/beta-policy') + return data +} + +/** + * Update beta policy settings + * @param settings - Beta policy settings to update + * @returns Updated settings + */ +export async function updateBetaPolicySettings( + settings: BetaPolicySettings +): Promise { + const { data } = await apiClient.put( + '/admin/settings/beta-policy', + settings + ) + return data +} + // ==================== Sora S3 Settings ==================== export interface SoraS3Settings { @@ -456,6 +499,8 @@ export const settingsAPI = { updateStreamTimeoutSettings, getRectifierSettings, updateRectifierSettings, + getBetaPolicySettings, + updateBetaPolicySettings, getSoraS3Settings, updateSoraS3Settings, testSoraS3Connection, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 9832ed85..4936674c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4043,6 +4043,23 @@ export default { saved: 'Rectifier settings saved', saveFailed: 'Failed to save rectifier settings' }, + betaPolicy: { + title: 'Beta Policy', + description: 'How to handle Beta features when configuring the forwarding of Anthropic API requests. Applicable only to the /v1/messages endpoint.', + action: 'Action', + actionPass: 'Pass (transparent)', + actionFilter: 'Filter (remove)', + actionBlock: 'Block (reject)', + scope: 'Scope', + scopeAll: 'All accounts', + scopeOAuth: 'OAuth only', + scopeAPIKey: 'API Key only', + errorMessage: 'Error message', + errorMessagePlaceholder: 'Custom error message when blocked', + errorMessageHint: 'Leave empty for default message', + saved: 'Beta policy settings saved', + saveFailed: 'Failed to save beta policy settings' + }, saveSettings: 'Save Settings', saving: 'Saving...', settingsSaved: 'Settings saved successfully', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 7ad89848..f438b020 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4216,6 +4216,23 @@ export default { saved: '整流器设置保存成功', saveFailed: '保存整流器设置失败' }, + betaPolicy: { + title: 'Beta 策略', + description: '配置转发 Anthropic API 请求时如何处理 Beta 特性。仅适用于 /v1/messages 接口。', + action: '处理方式', + actionPass: '透传(不处理)', + actionFilter: '过滤(移除)', + actionBlock: '拦截(拒绝请求)', + scope: '生效范围', + scopeAll: '全部账号', + scopeOAuth: '仅 OAuth 账号', + scopeAPIKey: '仅 API Key 账号', + errorMessage: '错误消息', + errorMessagePlaceholder: '拦截时返回的自定义错误消息', + errorMessageHint: '留空则使用默认错误消息', + saved: 'Beta 策略设置保存成功', + saveFailed: '保存 Beta 策略设置失败' + }, saveSettings: '保存设置', saving: '保存中...', settingsSaved: '设置保存成功', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c5d0f494..c2056ccb 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -405,6 +405,117 @@ + +
+
+

+ {{ t('admin.settings.betaPolicy.title') }} +

+

+ {{ t('admin.settings.betaPolicy.description') }} +

+
+
+ +
+
+ {{ t('common.loading') }} +
+ + +
+
+ @@ -1627,6 +1738,18 @@ const rectifierForm = reactive({ thinking_budget_enabled: true }) +// Beta Policy 状态 +const betaPolicyLoading = ref(true) +const betaPolicySaving = ref(false) +const betaPolicyForm = reactive({ + rules: [] as Array<{ + beta_token: string + action: 'pass' | 'filter' | 'block' + scope: 'all' | 'oauth' | 'apikey' + error_message?: string + }> +}) + interface DefaultSubscriptionGroupOption { value: number label: string @@ -2165,12 +2288,64 @@ async function saveRectifierSettings() { } } +const betaPolicyActionOptions = computed(() => [ + { value: 'pass', label: t('admin.settings.betaPolicy.actionPass') }, + { value: 'filter', label: t('admin.settings.betaPolicy.actionFilter') }, + { value: 'block', label: t('admin.settings.betaPolicy.actionBlock') } +]) + +const betaPolicyScopeOptions = computed(() => [ + { value: 'all', label: t('admin.settings.betaPolicy.scopeAll') }, + { value: 'oauth', label: t('admin.settings.betaPolicy.scopeOAuth') }, + { value: 'apikey', label: t('admin.settings.betaPolicy.scopeAPIKey') } +]) + +// Beta Policy 方法 +const betaDisplayNames: Record = { + 'fast-mode-2026-02-01': 'Fast Mode', + 'context-1m-2025-08-07': 'Context 1M' +} + +function getBetaDisplayName(token: string): string { + return betaDisplayNames[token] || token +} + +async function loadBetaPolicySettings() { + betaPolicyLoading.value = true + try { + const settings = await adminAPI.settings.getBetaPolicySettings() + betaPolicyForm.rules = settings.rules + } catch (error: any) { + console.error('Failed to load beta policy settings:', error) + } finally { + betaPolicyLoading.value = false + } +} + +async function saveBetaPolicySettings() { + betaPolicySaving.value = true + try { + const updated = await adminAPI.settings.updateBetaPolicySettings({ + rules: betaPolicyForm.rules + }) + betaPolicyForm.rules = updated.rules + appStore.showSuccess(t('admin.settings.betaPolicy.saved')) + } catch (error: any) { + appStore.showError( + t('admin.settings.betaPolicy.saveFailed') + ': ' + (error.message || t('common.unknownError')) + ) + } finally { + betaPolicySaving.value = false + } +} + onMounted(() => { loadSettings() loadSubscriptionGroups() loadAdminApiKey() loadStreamTimeoutSettings() loadRectifierSettings() + loadBetaPolicySettings() })