mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 02:07:32 +00:00
feat: Anthropic平台可配置 anthropic-beta 策略
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 存储配置
|
||||
// =========================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BetaPolicySettings> {
|
||||
const { data } = await apiClient.get<BetaPolicySettings>('/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<BetaPolicySettings> {
|
||||
const { data } = await apiClient.put<BetaPolicySettings>(
|
||||
'/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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '设置保存成功',
|
||||
|
||||
@@ -405,6 +405,117 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Beta Policy Settings -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.betaPolicy.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.betaPolicy.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="betaPolicyLoading" class="flex items-center gap-2 text-gray-500">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Rule Cards -->
|
||||
<div
|
||||
v-for="rule in betaPolicyForm.rules"
|
||||
:key="rule.beta_token"
|
||||
class="rounded-lg border border-gray-200 p-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ getBetaDisplayName(rule.beta_token) }}
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-400">
|
||||
{{ rule.beta_token }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.betaPolicy.action') }}
|
||||
</label>
|
||||
<Select
|
||||
:modelValue="rule.action"
|
||||
@update:modelValue="rule.action = $event as any"
|
||||
:options="betaPolicyActionOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scope -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.betaPolicy.scope') }}
|
||||
</label>
|
||||
<Select
|
||||
:modelValue="rule.scope"
|
||||
@update:modelValue="rule.scope = $event as any"
|
||||
:options="betaPolicyScopeOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message (only when action=block) -->
|
||||
<div v-if="rule.action === 'block'" class="mt-3">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.betaPolicy.errorMessage') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="rule.error_message"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.settings.betaPolicy.errorMessagePlaceholder')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t('admin.settings.betaPolicy.errorMessageHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="saveBetaPolicySettings"
|
||||
:disabled="betaPolicySaving"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<svg
|
||||
v-if="betaPolicySaving"
|
||||
class="mr-1 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{{ betaPolicySaving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /Tab: Gateway -->
|
||||
|
||||
<!-- Tab: Security — Registration, Turnstile, LinuxDo -->
|
||||
@@ -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<string, string> = {
|
||||
'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()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user