History

Merge pull request #5033 from DEAN-Cherry/fix/claude-fable-model-scoped-cooldown

fix(claude): keep Fable-only rate limits model scoped

Luis Pater committed 2 months ago

Commit f985a5c2 parents ee2c494 a8f98142 files · +246 −9Browse files

Unified · Split

2 files
  1. internal/runtime/executor/claude_executor_fable_ratelimit_test.go +221 −0
  2. internal/runtime/executor/helps/claude_ratelimit.go +25 −9
internal/runtime/executor/claude_executor_fable_ratelimit_test.go+221−0
@@ -0,0 +1,221 @@
1package executor
2
3import (
4 "context"
5 "errors"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strconv"
10 "strings"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "github.com/google/uuid"
16 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
17 "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
18 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
19 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
20 sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
21)
22
23func TestClassifyClaudeUpstreamError_FableOnlyRejectionIsModelScoped(t *testing.T) {
24 // Given
25 headers := http.Header{
26 "Anthropic-Ratelimit-Unified-Status": []string{"rejected"},
27 "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"},
28 "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"},
29 "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"},
30 "Retry-After": []string{"120"},
31 }
32
33 // When
34 err := classifyClaudeUpstreamError(http.StatusTooManyRequests, headers, []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Fable usage window rejected."}}`))
35
36 // Then
37 var scoped interface{ IsCredentialScoped() bool }
38 if !errors.As(err, &scoped) || scoped == nil {
39 t.Fatalf("expected %T to expose credential scope", err)
40 }
41 if scoped.IsCredentialScoped() {
42 t.Fatal("Fable-only 7d_oi rejection was credential-scoped; want model-scoped")
43 }
44}
45
46func TestClassifyClaudeUpstreamError_SharedOrAmbiguousRejectionRemainsCredentialScoped(t *testing.T) {
47 tests := []struct {
48 name string
49 headers http.Header
50 }{
51 {
52 name: "explicit 5h rejection",
53 headers: http.Header{
54 "Anthropic-Ratelimit-Unified-5h-Status": []string{"rejected"},
55 "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"},
56 },
57 },
58 {
59 name: "explicit shared 7d rejection",
60 headers: http.Header{
61 "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"},
62 "Anthropic-Ratelimit-Unified-7d-Status": []string{"rejected"},
63 },
64 },
65 {
66 name: "aggregate rejection with shared statuses missing",
67 headers: http.Header{
68 "Anthropic-Ratelimit-Unified-Status": []string{"rejected"},
69 },
70 },
71 {
72 name: "aggregate rejection with shared statuses malformed",
73 headers: http.Header{
74 "Anthropic-Ratelimit-Unified-Status": []string{"rejected"},
75 "Anthropic-Ratelimit-Unified-5h-Status": []string{"unknown"},
76 "Anthropic-Ratelimit-Unified-7d-Status": []string{"invalid"},
77 },
78 },
79 }
80
81 for _, tt := range tests {
82 t.Run(tt.name, func(t *testing.T) {
83 // When
84 err := classifyClaudeUpstreamError(http.StatusTooManyRequests, tt.headers, []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Shared usage window rejected."}}`))
85
86 // Then
87 var scoped interface{ IsCredentialScoped() bool }
88 if !errors.As(err, &scoped) || scoped == nil {
89 t.Fatalf("expected %T to expose credential scope", err)
90 }
91 if !scoped.IsCredentialScoped() {
92 t.Fatal("shared or ambiguous rejection was model-scoped; want credential-scoped")
93 }
94 })
95 }
96}
97
98func TestClassifyClaudeUpstreamError_FableRetryDurationRemainsAvailable(t *testing.T) {
99 tests := []struct {
100 name string
101 headers http.Header
102 min, max time.Duration
103 }{
104 {
105 name: "7d_oi reset",
106 headers: http.Header{
107 "Anthropic-Ratelimit-Unified-Status": []string{"rejected"},
108 "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"},
109 "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"},
110 "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"},
111 "Anthropic-Ratelimit-Unified-7d_oi-Reset": []string{strconv.FormatInt(time.Now().Add(2*time.Hour).Unix(), 10)},
112 },
113 min: 2*time.Hour - 5*time.Second,
114 max: 2*time.Hour + 35*time.Second,
115 },
116 {
117 name: "retry-after",
118 headers: http.Header{
119 "Anthropic-Ratelimit-Unified-Status": []string{"rejected"},
120 "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"},
121 "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"},
122 "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"},
123 "Retry-After": []string{"120"},
124 },
125 min: 2 * time.Minute,
126 max: 2*time.Minute + 30*time.Second,
127 },
128 }
129
130 for _, tt := range tests {
131 t.Run(tt.name, func(t *testing.T) {
132 // When
133 err := classifyClaudeUpstreamError(http.StatusTooManyRequests, tt.headers, []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Fable usage window rejected."}}`))
134
135 // Then
136 var retry retryAfterProvider
137 if !errors.As(err, &retry) || retry == nil || retry.RetryAfter() == nil {
138 t.Fatalf("expected Fable rate-limit error to retain a retry duration, got %v", err)
139 }
140 if got := *retry.RetryAfter(); got < tt.min || got > tt.max {
141 t.Fatalf("RetryAfter = %v, want between %v and %v", got, tt.min, tt.max)
142 }
143 })
144 }
145}
146
147func TestClaudeExecutor_AuthManager_FableOnlyRejectionDoesNotBlockOpus(t *testing.T) {
148 var fableAttempts, opusAttempts atomic.Int32
149 reset := time.Now().Add(2 * time.Hour).Unix()
150 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151 body, errRead := io.ReadAll(r.Body)
152 if errRead != nil {
153 http.Error(w, "failed to read sanitized test request", http.StatusBadRequest)
154 return
155 }
156 switch {
157 case strings.Contains(string(body), `"model":"claude-fable-5"`):
158 fableAttempts.Add(1)
159 w.Header().Set("Content-Type", "application/json")
160 w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
161 w.Header().Set("Anthropic-Ratelimit-Unified-5h-Status", "allowed")
162 w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed")
163 w.Header().Set("Anthropic-Ratelimit-Unified-7d_oi-Status", "rejected")
164 w.Header().Set("Anthropic-Ratelimit-Unified-7d_oi-Reset", strconv.FormatInt(reset, 10))
165 w.Header().Set("Retry-After", "120")
166 w.WriteHeader(http.StatusTooManyRequests)
167 _, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Fable usage window rejected."}}`))
168 case strings.Contains(string(body), `"model":"claude-opus-5"`):
169 opusAttempts.Add(1)
170 w.Header().Set("Content-Type", "application/json")
171 w.WriteHeader(http.StatusOK)
172 _, _ = w.Write([]byte(`{"id":"msg-opus-ok","type":"message","model":"claude-opus-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`))
173 default:
174 http.Error(w, "unexpected sanitized test model", http.StatusBadRequest)
175 }
176 }))
177 defer server.Close()
178
179 manager := cliproxyauth.NewManager(nil, nil, nil)
180 manager.SetRetryConfig(0, 0, 0)
181 manager.RegisterExecutor(NewClaudeExecutor(&config.Config{DisableCooling: false}))
182
183 auth := &cliproxyauth.Auth{
184 ID: uuid.NewString() + "-fable-model-scope",
185 Provider: "claude",
186 Attributes: map[string]string{
187 "api_key": "sanitized-test-key",
188 "base_url": server.URL,
189 },
190 }
191 reg := registry.GetGlobalRegistry()
192 reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: "claude-fable-5"}, {ID: "claude-opus-5"}})
193 t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
194 if _, err := manager.Register(context.Background(), auth); err != nil {
195 t.Fatalf("register auth: %v", err)
196 }
197
198 payloadFable := []byte(`{"model":"claude-fable-5","messages":[{"role":"user","content":[{"type":"text","text":"test"}]}]}`)
199 _, errFable := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
200 Model: "claude-fable-5",
201 Payload: payloadFable,
202 }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
203 if errFable == nil {
204 t.Fatal("expected Fable request to be rate limited")
205 }
206 if got := fableAttempts.Load(); got != 1 {
207 t.Fatalf("Fable upstream attempts = %d, want 1", got)
208 }
209
210 payloadOpus := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":[{"type":"text","text":"test"}]}]}`)
211 _, errOpus := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
212 Model: "claude-opus-5",
213 Payload: payloadOpus,
214 }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
215 if errOpus != nil {
216 t.Fatalf("expected Opus to reach upstream on the same credential, got: %v", errOpus)
217 }
218 if got := opusAttempts.Load(); got != 1 {
219 t.Fatalf("Opus upstream attempts = %d, want 1", got)
220 }
221}
internal/runtime/executor/helps/claude_ratelimit.go+25−9
@@ -17,15 +17,13 @@ const ( · 16 unmodified lines
17)17)
18 18
19// ClaudeHeadersIndicateUnifiedRateLimitRejection reports whether response headers explicitly19// ClaudeHeadersIndicateUnifiedRateLimitRejection reports whether response headers explicitly
20// declare an Anthropic unified 5h or 7d rate-limit rejection.20// declare an Anthropic shared 5h or 7d rate-limit rejection. A Fable-only 7d_oi rejection
21// remains model-scoped when both shared windows are explicitly allowed.
21func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool {22func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool {
22 if headers == nil {23 if headers == nil {
23 return false24 return false
24 }25 }
25 unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status")))26 unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status")))
26 if unifiedStatus == "rejected" {
27 return true
28 }
29 status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status")))27 status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status")))
30 if status5h == "rejected" {28 if status5h == "rejected" {
31 return true29 return true
@@ -34,11 +32,16 @@ func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { · 2 unmodified lines
34 if status7d == "rejected" {32 if status7d == "rejected" {
35 return true33 return true
36 }34 }
37 return false35 if unifiedStatus != "rejected" {
36 return false
37 }
38 status7dOI := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Status")))
39 fableOnlyRejection := status5h == "allowed" && status7d == "allowed" && status7dOI == "rejected"
40 return !fableOnlyRejection
38}41}
39 42
40// ParseClaudeRateLimitReset inspects Anthropic response headers for unified rate-limit43// ParseClaudeRateLimitReset inspects Anthropic response headers for shared and Fable-specific
41// and standard Retry-After reset information, returning the conservative cooldown44// unified rate-limit and standard Retry-After reset information, returning the conservative cooldown
42// duration including a bounded non-negative random grace period.45// duration including a bounded non-negative random grace period.
43// If no valid future reset information is present, it returns nil.46// If no valid future reset information is present, it returns nil.
44func ParseClaudeRateLimitReset(headers http.Header, now time.Time) *time.Duration {47func ParseClaudeRateLimitReset(headers http.Header, now time.Time) *time.Duration {
@@ -53,6 +56,7 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu · 8 unmodified lines
53 unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status")))56 unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status")))
54 status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status")))57 status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status")))
55 status7d := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Status")))58 status7d := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Status")))
59 status7dOI := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Status")))
56 60
57 var candidateDeadlines []time.Time61 var candidateDeadlines []time.Time
58 var rejectedWindows []string62 var rejectedWindows []string
@@ -66,6 +70,9 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu · 7 unmodified lines
66 if status7d == "rejected" {70 if status7d == "rejected" {
67 rejectedWindows = append(rejectedWindows, "7d")71 rejectedWindows = append(rejectedWindows, "7d")
68 }72 }
73 if status7dOI == "rejected" {
74 rejectedWindows = append(rejectedWindows, "7d_oi")
75 }
69 76
70 // 1. Retry-After header77 // 1. Retry-After header
71 if rawRetryAfter := getHeaderCaseInsensitive(headers, "Retry-After"); rawRetryAfter != "" {78 if rawRetryAfter := getHeaderCaseInsensitive(headers, "Retry-After"); rawRetryAfter != "" {
@@ -95,8 +102,17 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu · 23 unmodified lines
95 }102 }
96 }103 }
97 104
98 // 4. Unified reset header:105 // 4. Fable-specific 7-day window reset (only when rejected)
99 unifiedRejected := unifiedStatus == "rejected" || status5h == "rejected" || status7d == "rejected" ||106 if status7dOI == "rejected" {
107 if raw := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Reset"); raw != "" {
108 if t, ok := parseUnixOrTimestamp(raw); ok && t.After(now) {
109 candidateDeadlines = append(candidateDeadlines, t)
110 }
111 }
112 }
113
114 // 5. Unified reset header:
115 unifiedRejected := unifiedStatus == "rejected" || status5h == "rejected" || status7d == "rejected" || status7dOI == "rejected" ||
100 (unifiedStatus == "" && status5h != "allowed" && status7d != "allowed")116 (unifiedStatus == "" && status5h != "allowed" && status7d != "allowed")
101 117
102 if unifiedRejected {118 if unifiedRejected {