From 4ac57b4edf7f97fa9766b0a44d01731968e9ad02 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 26 Feb 2026 15:42:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=B4=E6=97=B6=E7=A7=BB=E9=99=A4fast?= =?UTF-8?q?-mode-2026-02-01=E9=81=BF=E5=85=8D429=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/pkg/claude/constants.go | 5 ++ backend/internal/service/gateway_beta_test.go | 72 +++++++++++++++---- backend/internal/service/gateway_service.go | 44 +++++++++--- 3 files changed, 97 insertions(+), 24 deletions(-) diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 423ad925..22405382 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -11,8 +11,13 @@ const ( BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14" BetaTokenCounting = "token-counting-2024-11-01" BetaContext1M = "context-1m-2025-08-07" + BetaFastMode = "fast-mode-2026-02-01" ) +// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。 +// 这些 token 是客户端特有的,不应透传给上游 API。 +var DroppedBetas = []string{BetaContext1M, BetaFastMode} + // DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming diff --git a/backend/internal/service/gateway_beta_test.go b/backend/internal/service/gateway_beta_test.go index d7108c8d..c682e286 100644 --- a/backend/internal/service/gateway_beta_test.go +++ b/backend/internal/service/gateway_beta_test.go @@ -3,6 +3,8 @@ package service import ( "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/stretchr/testify/require" ) @@ -22,60 +24,78 @@ func TestMergeAnthropicBeta_EmptyIncoming(t *testing.T) { require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14", got) } -func TestStripBetaToken(t *testing.T) { +func TestStripBetaTokens(t *testing.T) { tests := []struct { name string header string - token string + tokens []string want string }{ { - name: "token in middle", + name: "single token in middle", header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14", - token: "context-1m-2025-08-07", + tokens: []string{"context-1m-2025-08-07"}, want: "oauth-2025-04-20,interleaved-thinking-2025-05-14", }, { - name: "token at start", + name: "single token at start", header: "context-1m-2025-08-07,oauth-2025-04-20,interleaved-thinking-2025-05-14", - token: "context-1m-2025-08-07", + tokens: []string{"context-1m-2025-08-07"}, want: "oauth-2025-04-20,interleaved-thinking-2025-05-14", }, { - name: "token at end", + name: "single token at end", header: "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07", - token: "context-1m-2025-08-07", + tokens: []string{"context-1m-2025-08-07"}, want: "oauth-2025-04-20,interleaved-thinking-2025-05-14", }, { name: "token not present", header: "oauth-2025-04-20,interleaved-thinking-2025-05-14", - token: "context-1m-2025-08-07", + tokens: []string{"context-1m-2025-08-07"}, want: "oauth-2025-04-20,interleaved-thinking-2025-05-14", }, { name: "empty header", header: "", - token: "context-1m-2025-08-07", + tokens: []string{"context-1m-2025-08-07"}, want: "", }, { name: "with spaces", header: "oauth-2025-04-20, context-1m-2025-08-07 , interleaved-thinking-2025-05-14", - token: "context-1m-2025-08-07", + tokens: []string{"context-1m-2025-08-07"}, want: "oauth-2025-04-20,interleaved-thinking-2025-05-14", }, { name: "only token", header: "context-1m-2025-08-07", - token: "context-1m-2025-08-07", + tokens: []string{"context-1m-2025-08-07"}, want: "", }, + { + name: "nil tokens", + header: "oauth-2025-04-20,interleaved-thinking-2025-05-14", + tokens: nil, + want: "oauth-2025-04-20,interleaved-thinking-2025-05-14", + }, + { + name: "multiple tokens removed", + header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,fast-mode-2026-02-01", + tokens: []string{"context-1m-2025-08-07", "fast-mode-2026-02-01"}, + want: "oauth-2025-04-20,interleaved-thinking-2025-05-14", + }, + { + name: "DroppedBetas removes both context-1m and fast-mode", + 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,interleaved-thinking-2025-05-14", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := stripBetaToken(tt.header, tt.token) + got := stripBetaTokens(tt.header, tt.tokens) require.Equal(t, tt.want, got) }) } @@ -90,3 +110,29 @@ func TestMergeAnthropicBetaDropping_Context1M(t *testing.T) { require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got) require.NotContains(t, got, "context-1m-2025-08-07") } + +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" + drop := droppedBetaSet() + + got := mergeAnthropicBetaDropping(required, incoming, drop) + require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got) + require.NotContains(t, got, "context-1m-2025-08-07") + require.NotContains(t, got, "fast-mode-2026-02-01") +} + +func TestDroppedBetaSet(t *testing.T) { + // Base set contains DroppedBetas + base := droppedBetaSet() + require.Contains(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.Contains(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 dea96436..61e8c4c6 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4425,12 +4425,12 @@ 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} - drop := map[string]struct{}{claude.BetaClaudeCode: {}, claude.BetaContext1M: {}} + drop := droppedBetaSet(claude.BetaClaudeCode) req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop)) } else { // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta clientBetaHeader := req.Header.Get("anthropic-beta") - req.Header.Set("anthropic-beta", stripBetaToken(s.getBetaHeader(modelID, clientBetaHeader), claude.BetaContext1M)) + req.Header.Set("anthropic-beta", stripBetaTokens(s.getBetaHeader(modelID, clientBetaHeader), claude.DroppedBetas)) } } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" { // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭) @@ -4584,23 +4584,45 @@ func mergeAnthropicBetaDropping(required []string, incoming string, drop map[str return strings.Join(out, ",") } -// stripBetaToken removes a single beta token from a comma-separated header value. -// It short-circuits when the token is not present to avoid unnecessary allocations. -func stripBetaToken(header, token string) string { - if !strings.Contains(header, token) { +// stripBetaTokens removes the given beta tokens from a comma-separated header value. +func stripBetaTokens(header string, tokens []string) string { + if header == "" || len(tokens) == 0 { return header } - out := make([]string, 0, 8) - for _, p := range strings.Split(header, ",") { + drop := make(map[string]struct{}, len(tokens)) + for _, t := range tokens { + drop[t] = struct{}{} + } + parts := strings.Split(header, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { p = strings.TrimSpace(p) - if p == "" || p == token { + if p == "" { + continue + } + if _, ok := drop[p]; ok { continue } out = append(out, p) } + if len(out) == len(parts) { + return header // no change, avoid allocation + } return strings.Join(out, ",") } +// droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens. +func droppedBetaSet(extra ...string) map[string]struct{} { + m := make(map[string]struct{}, len(claude.DroppedBetas)+len(extra)) + for _, t := range claude.DroppedBetas { + m[t] = struct{}{} + } + for _, t := range extra { + m[t] = struct{}{} + } + return m +} + // applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers. // This mirrors opencode-anthropic-auth behavior: do not trust downstream // headers when using Claude Code-scoped OAuth credentials. @@ -6385,7 +6407,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 := map[string]struct{}{claude.BetaContext1M: {}} + drop := droppedBetaSet() req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop)) } else { clientBetaHeader := req.Header.Get("anthropic-beta") @@ -6396,7 +6418,7 @@ 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", stripBetaToken(beta, claude.BetaContext1M)) + req.Header.Set("anthropic-beta", stripBetaTokens(beta, claude.DroppedBetas)) } } } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {