mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 14:39:26 +08:00
Merge pull request #5033 from DEAN-Cherry/fix/claude-fable-model-scoped-cooldown
fix(claude): keep Fable-only rate limits model scoped
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user