fix(claude): keep Fable-only rate limits model scoped
Unified · Split
| @@ -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 | } | ||
| @@ -17,15 +17,13 @@ const ( · 16 unmodified lines | |||
| 17 | ) | 17 | ) |
| 18 | 18 | ||
| 19 | // ClaudeHeadersIndicateUnifiedRateLimitRejection reports whether response headers explicitly | 19 | // 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. | ||
| 21 | func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { | 22 | func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { |
| 22 | if headers == nil { | 23 | if headers == nil { |
| 23 | return false | 24 | 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 true | 29 | 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 true | 33 | return true |
| 36 | } | 34 | } |
| 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 | ||
| 38 | } | 41 | } |
| 39 | 42 | ||
| 40 | // ParseClaudeRateLimitReset inspects Anthropic response headers for unified rate-limit | 43 | // ParseClaudeRateLimitReset inspects Anthropic response headers for shared and Fable-specific |
| 41 | // and standard Retry-After reset information, returning the conservative cooldown | 44 | // 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. |
| 44 | func ParseClaudeRateLimitReset(headers http.Header, now time.Time) *time.Duration { | 47 | 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 | |||
| 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.Time | 61 | var candidateDeadlines []time.Time |
| 58 | var rejectedWindows []string | 62 | 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 header | 77 | // 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 { |