fix: 临时移除fast-mode-2026-02-01避免429问题
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") == "" {
|
||||
|
||||
Reference in New Issue
Block a user