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 | 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 | 22 | func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { |
| 22 | 23 | if headers == nil { |
| 23 | 24 | return false |
| 24 | 25 | } |
| 25 | 26 | unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status"))) |
| 26 | − if unifiedStatus == "rejected" { | |
| 27 | − return true | |
| 28 | − } | |
| 29 | 27 | status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status"))) |
| 30 | 28 | if status5h == "rejected" { |
| 31 | 29 | return true |
| @@ -34,11 +32,16 @@ func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { · 2 unmodified lines | ||
| 34 | 32 | if status7d == "rejected" { |
| 35 | 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 | |
| 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 | |
| 42 | 45 | // duration including a bounded non-negative random grace period. |
| 43 | 46 | // If no valid future reset information is present, it returns nil. |
| 44 | 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 | 56 | unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status"))) |
| 54 | 57 | status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status"))) |
| 55 | 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 | 61 | var candidateDeadlines []time.Time |
| 58 | 62 | var rejectedWindows []string |
| @@ -66,6 +70,9 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu · 7 unmodified lines | ||
| 66 | 70 | if status7d == "rejected" { |
| 67 | 71 | rejectedWindows = append(rejectedWindows, "7d") |
| 68 | 72 | } |
| 73 | + if status7dOI == "rejected" { | |
| 74 | + rejectedWindows = append(rejectedWindows, "7d_oi") | |
| 75 | + } | |
| 69 | 76 | |
| 70 | 77 | // 1. Retry-After header |
| 71 | 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: | |
| 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" || | |
| 100 | 116 | (unifiedStatus == "" && status5h != "allowed" && status7d != "allowed") |
| 101 | 117 | |
| 102 | 118 | if unifiedRejected { |