diff --git a/internal/runtime/executor/claude_executor_fable_ratelimit_test.go b/internal/runtime/executor/claude_executor_fable_ratelimit_test.go new file mode 100644 index 000000000..8efe77cf9 --- /dev/null +++ b/internal/runtime/executor/claude_executor_fable_ratelimit_test.go @@ -0,0 +1,221 @@ +package executor + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) + +func TestClassifyClaudeUpstreamError_FableOnlyRejectionIsModelScoped(t *testing.T) { + // Given + headers := http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + "Retry-After": []string{"120"}, + } + + // When + err := classifyClaudeUpstreamError(http.StatusTooManyRequests, headers, []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Fable usage window rejected."}}`)) + + // Then + var scoped interface{ IsCredentialScoped() bool } + if !errors.As(err, &scoped) || scoped == nil { + t.Fatalf("expected %T to expose credential scope", err) + } + if scoped.IsCredentialScoped() { + t.Fatal("Fable-only 7d_oi rejection was credential-scoped; want model-scoped") + } +} + +func TestClassifyClaudeUpstreamError_SharedOrAmbiguousRejectionRemainsCredentialScoped(t *testing.T) { + tests := []struct { + name string + headers http.Header + }{ + { + name: "explicit 5h rejection", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-5h-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"}, + }, + }, + { + name: "explicit shared 7d rejection", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"rejected"}, + }, + }, + { + name: "aggregate rejection with shared statuses missing", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + }, + }, + { + name: "aggregate rejection with shared statuses malformed", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"unknown"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"invalid"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // When + err := classifyClaudeUpstreamError(http.StatusTooManyRequests, tt.headers, []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Shared usage window rejected."}}`)) + + // Then + var scoped interface{ IsCredentialScoped() bool } + if !errors.As(err, &scoped) || scoped == nil { + t.Fatalf("expected %T to expose credential scope", err) + } + if !scoped.IsCredentialScoped() { + t.Fatal("shared or ambiguous rejection was model-scoped; want credential-scoped") + } + }) + } +} + +func TestClassifyClaudeUpstreamError_FableRetryDurationRemainsAvailable(t *testing.T) { + tests := []struct { + name string + headers http.Header + min, max time.Duration + }{ + { + name: "7d_oi reset", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-7d_oi-Reset": []string{strconv.FormatInt(time.Now().Add(2*time.Hour).Unix(), 10)}, + }, + min: 2*time.Hour - 5*time.Second, + max: 2*time.Hour + 35*time.Second, + }, + { + name: "retry-after", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + "Retry-After": []string{"120"}, + }, + min: 2 * time.Minute, + max: 2*time.Minute + 30*time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // When + err := classifyClaudeUpstreamError(http.StatusTooManyRequests, tt.headers, []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Fable usage window rejected."}}`)) + + // Then + var retry retryAfterProvider + if !errors.As(err, &retry) || retry == nil || retry.RetryAfter() == nil { + t.Fatalf("expected Fable rate-limit error to retain a retry duration, got %v", err) + } + if got := *retry.RetryAfter(); got < tt.min || got > tt.max { + t.Fatalf("RetryAfter = %v, want between %v and %v", got, tt.min, tt.max) + } + }) + } +} + +func TestClaudeExecutor_AuthManager_FableOnlyRejectionDoesNotBlockOpus(t *testing.T) { + var fableAttempts, opusAttempts atomic.Int32 + reset := time.Now().Add(2 * time.Hour).Unix() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + http.Error(w, "failed to read sanitized test request", http.StatusBadRequest) + return + } + switch { + case strings.Contains(string(body), `"model":"claude-fable-5"`): + fableAttempts.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected") + w.Header().Set("Anthropic-Ratelimit-Unified-5h-Status", "allowed") + w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed") + w.Header().Set("Anthropic-Ratelimit-Unified-7d_oi-Status", "rejected") + w.Header().Set("Anthropic-Ratelimit-Unified-7d_oi-Reset", strconv.FormatInt(reset, 10)) + w.Header().Set("Retry-After", "120") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Fable usage window rejected."}}`)) + case strings.Contains(string(body), `"model":"claude-opus-5"`): + opusAttempts.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"msg-opus-ok","type":"message","model":"claude-opus-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + default: + http.Error(w, "unexpected sanitized test model", http.StatusBadRequest) + } + })) + defer server.Close() + + manager := cliproxyauth.NewManager(nil, nil, nil) + manager.SetRetryConfig(0, 0, 0) + manager.RegisterExecutor(NewClaudeExecutor(&config.Config{DisableCooling: false})) + + auth := &cliproxyauth.Auth{ + ID: uuid.NewString() + "-fable-model-scope", + Provider: "claude", + Attributes: map[string]string{ + "api_key": "sanitized-test-key", + "base_url": server.URL, + }, + } + reg := registry.GetGlobalRegistry() + reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: "claude-fable-5"}, {ID: "claude-opus-5"}}) + t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) + if _, err := manager.Register(context.Background(), auth); err != nil { + t.Fatalf("register auth: %v", err) + } + + payloadFable := []byte(`{"model":"claude-fable-5","messages":[{"role":"user","content":[{"type":"text","text":"test"}]}]}`) + _, errFable := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{ + Model: "claude-fable-5", + Payload: payloadFable, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if errFable == nil { + t.Fatal("expected Fable request to be rate limited") + } + if got := fableAttempts.Load(); got != 1 { + t.Fatalf("Fable upstream attempts = %d, want 1", got) + } + + payloadOpus := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":[{"type":"text","text":"test"}]}]}`) + _, errOpus := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{ + Model: "claude-opus-5", + Payload: payloadOpus, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if errOpus != nil { + t.Fatalf("expected Opus to reach upstream on the same credential, got: %v", errOpus) + } + if got := opusAttempts.Load(); got != 1 { + t.Fatalf("Opus upstream attempts = %d, want 1", got) + } +} diff --git a/internal/runtime/executor/helps/claude_ratelimit.go b/internal/runtime/executor/helps/claude_ratelimit.go index bd091cc51..88a84a66b 100644 --- a/internal/runtime/executor/helps/claude_ratelimit.go +++ b/internal/runtime/executor/helps/claude_ratelimit.go @@ -17,15 +17,13 @@ const ( ) // ClaudeHeadersIndicateUnifiedRateLimitRejection reports whether response headers explicitly -// declare an Anthropic unified 5h or 7d rate-limit rejection. +// declare an Anthropic shared 5h or 7d rate-limit rejection. A Fable-only 7d_oi rejection +// remains model-scoped when both shared windows are explicitly allowed. func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { if headers == nil { return false } unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status"))) - if unifiedStatus == "rejected" { - return true - } status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status"))) if status5h == "rejected" { return true @@ -34,11 +32,16 @@ func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { if status7d == "rejected" { return true } - return false + if unifiedStatus != "rejected" { + return false + } + status7dOI := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Status"))) + fableOnlyRejection := status5h == "allowed" && status7d == "allowed" && status7dOI == "rejected" + return !fableOnlyRejection } -// ParseClaudeRateLimitReset inspects Anthropic response headers for unified rate-limit -// and standard Retry-After reset information, returning the conservative cooldown +// ParseClaudeRateLimitReset inspects Anthropic response headers for shared and Fable-specific +// unified rate-limit and standard Retry-After reset information, returning the conservative cooldown // duration including a bounded non-negative random grace period. // If no valid future reset information is present, it returns nil. func ParseClaudeRateLimitReset(headers http.Header, now time.Time) *time.Duration { @@ -53,6 +56,7 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status"))) status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status"))) status7d := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Status"))) + status7dOI := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Status"))) var candidateDeadlines []time.Time var rejectedWindows []string @@ -66,6 +70,9 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu if status7d == "rejected" { rejectedWindows = append(rejectedWindows, "7d") } + if status7dOI == "rejected" { + rejectedWindows = append(rejectedWindows, "7d_oi") + } // 1. Retry-After header if rawRetryAfter := getHeaderCaseInsensitive(headers, "Retry-After"); rawRetryAfter != "" { @@ -95,8 +102,17 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu } } - // 4. Unified reset header: - unifiedRejected := unifiedStatus == "rejected" || status5h == "rejected" || status7d == "rejected" || + // 4. Fable-specific 7-day window reset (only when rejected) + if status7dOI == "rejected" { + if raw := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Reset"); raw != "" { + if t, ok := parseUnixOrTimestamp(raw); ok && t.After(now) { + candidateDeadlines = append(candidateDeadlines, t) + } + } + } + + // 5. Unified reset header: + unifiedRejected := unifiedStatus == "rejected" || status5h == "rejected" || status7d == "rejected" || status7dOI == "rejected" || (unifiedStatus == "" && status5h != "allowed" && status7d != "allowed") if unifiedRejected {