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 @@
1+package executor
2+
3+import (
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+
23+func 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+
46+func 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+
98+func 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+
147+func 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
1717 )
1818
1919 // 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.
2122 func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool {
2223 if headers == nil {
2324 return false
2425 }
2526 unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status")))
26− if unifiedStatus == "rejected" {
27− return true
28− }
2927 status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status")))
3028 if status5h == "rejected" {
3129 return true
@@ -34,11 +32,16 @@ func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { · 2 unmodified lines
3432 if status7d == "rejected" {
3533 return true
3634 }
37− return false
35+ 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
3841 }
3942
40−// ParseClaudeRateLimitReset inspects Anthropic response headers for unified rate-limit
41−// and standard Retry-After reset information, returning the conservative cooldown
43+// ParseClaudeRateLimitReset inspects Anthropic response headers for shared and Fable-specific
44+// unified rate-limit and standard Retry-After reset information, returning the conservative cooldown
4245 // duration including a bounded non-negative random grace period.
4346 // If no valid future reset information is present, it returns nil.
4447 func 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
5356 unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status")))
5457 status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status")))
5558 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")))
5660
5761 var candidateDeadlines []time.Time
5862 var rejectedWindows []string
@@ -66,6 +70,9 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu · 7 unmodified lines
6670 if status7d == "rejected" {
6771 rejectedWindows = append(rejectedWindows, "7d")
6872 }
73+ if status7dOI == "rejected" {
74+ rejectedWindows = append(rejectedWindows, "7d_oi")
75+ }
6976
7077 // 1. Retry-After header
7178 if rawRetryAfter := getHeaderCaseInsensitive(headers, "Retry-After"); rawRetryAfter != "" {
@@ -95,8 +102,17 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu · 23 unmodified lines
95102 }
96103 }
97104
98− // 4. Unified reset header:
99− unifiedRejected := unifiedStatus == "rejected" || status5h == "rejected" || status7d == "rejected" ||
105+ // 4. Fable-specific 7-day window reset (only when 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" ||
100116 (unifiedStatus == "" && status5h != "allowed" && status7d != "allowed")
101117
102118 if unifiedRejected {