mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
Track when executor calls cross an upstream transport boundary and use that signal to keep model/provider errors from being replaced by later local preparation, selection, or internal failures. Mark HTTP, websocket, relay, and usage-tracked transports as upstream attempts, while avoiding marks for local validation, logging, missing sessions, and successful websocket handshakes before request send. Parse relative auth expiry metadata and adjust Antigravity refresh timing.
795 lines
30 KiB
Go
795 lines
30 KiB
Go
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"
|
|
)
|
|
|
|
type retryAfterProvider interface {
|
|
RetryAfter() *time.Duration
|
|
}
|
|
|
|
func TestClaudeExecutor_HonorsAnthropicRateLimitHeaders_Execute(t *testing.T) {
|
|
now := time.Now()
|
|
sevenDayReset := now.Add(7 * 24 * time.Hour).Unix()
|
|
fiveHourReset := now.Add(5 * time.Hour).Unix()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
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-5h-Reset", strconv.FormatInt(fiveHourReset, 10))
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", strconv.FormatInt(sevenDayReset, 10))
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-Representative-Claim", "seven_day")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-Reset", strconv.FormatInt(sevenDayReset, 10))
|
|
w.Header().Set("Retry-After", strconv.FormatInt(7*24*3600, 10))
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Number of requests has exceeded your 7-day rate limit."}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
executor := NewClaudeExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "claude-auth-1",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key",
|
|
"base_url": server.URL,
|
|
},
|
|
}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err == nil {
|
|
t.Fatal("expected error from Execute, got nil")
|
|
}
|
|
|
|
var rap retryAfterProvider
|
|
if !errors.As(err, &rap) || rap == nil {
|
|
t.Fatalf("expected error %T to implement RetryAfter() *time.Duration", err)
|
|
}
|
|
|
|
retryAfter := rap.RetryAfter()
|
|
if retryAfter == nil {
|
|
t.Fatalf("expected non-nil RetryAfter, got nil")
|
|
}
|
|
|
|
// Should be at least 7 days (reported reset) and at most 7 days + 35s (fuzz upper bound).
|
|
minExpected := 7*24*time.Hour - 5*time.Second
|
|
maxExpected := 7*24*time.Hour + 35*time.Second
|
|
if *retryAfter < minExpected || *retryAfter > maxExpected {
|
|
t.Fatalf("RetryAfter = %v, want between %v and %v", *retryAfter, minExpected, maxExpected)
|
|
}
|
|
|
|
// Verify one-time fuzz stability: repeat calls return exact same value
|
|
if second := rap.RetryAfter(); second == nil || *second != *retryAfter {
|
|
t.Fatalf("RetryAfter changed across calls: %v vs %v", *second, *retryAfter)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_HonorsAnthropicRateLimitHeaders_ExecuteStream(t *testing.T) {
|
|
now := time.Now()
|
|
fiveHourReset := now.Add(5 * time.Hour).Unix()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-5h-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-5h-Reset", strconv.FormatInt(fiveHourReset, 10))
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", strconv.FormatInt(now.Add(7*24*time.Hour).Unix(), 10))
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"5-hour limit exceeded."}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
executor := NewClaudeExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "claude-auth-1",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key",
|
|
"base_url": server.URL,
|
|
},
|
|
}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
_, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err == nil {
|
|
t.Fatal("expected error from ExecuteStream, got nil")
|
|
}
|
|
|
|
var rap retryAfterProvider
|
|
if !errors.As(err, &rap) || rap == nil {
|
|
t.Fatalf("expected error %T to implement RetryAfter() *time.Duration", err)
|
|
}
|
|
|
|
retryAfter := rap.RetryAfter()
|
|
if retryAfter == nil {
|
|
t.Fatalf("expected non-nil RetryAfter, got nil")
|
|
}
|
|
|
|
minExpected := 5*time.Hour - 5*time.Second
|
|
maxExpected := 5*time.Hour + 35*time.Second
|
|
if *retryAfter < minExpected || *retryAfter > maxExpected {
|
|
t.Fatalf("RetryAfter = %v, want between %v and %v (5h window)", *retryAfter, minExpected, maxExpected)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_RateLimit_BothRejectedUsesLongest(t *testing.T) {
|
|
now := time.Now()
|
|
fiveHourReset := now.Add(5 * time.Hour).Unix()
|
|
sevenDayReset := now.Add(7 * 24 * time.Hour).Unix()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-5h-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-5h-Reset", strconv.FormatInt(fiveHourReset, 10))
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", strconv.FormatInt(sevenDayReset, 10))
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Both limits exceeded."}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
executor := NewClaudeExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "claude-auth-1",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key",
|
|
"base_url": server.URL,
|
|
},
|
|
}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
var rap retryAfterProvider
|
|
if !errors.As(err, &rap) || rap == nil {
|
|
t.Fatalf("expected error %T to implement RetryAfter() *time.Duration", err)
|
|
}
|
|
|
|
retryAfter := rap.RetryAfter()
|
|
if retryAfter == nil {
|
|
t.Fatalf("expected non-nil RetryAfter, got nil")
|
|
}
|
|
|
|
minExpected := 7*24*time.Hour - 5*time.Second
|
|
maxExpected := 7*24*time.Hour + 35*time.Second
|
|
if *retryAfter < minExpected || *retryAfter > maxExpected {
|
|
t.Fatalf("RetryAfter = %v, want between %v and %v", *retryAfter, minExpected, maxExpected)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_RateLimit_CountTokensHonorsRateLimitReset(t *testing.T) {
|
|
now := time.Now()
|
|
sevenDayReset := now.Add(7 * 24 * time.Hour).Unix()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", strconv.FormatInt(sevenDayReset, 10))
|
|
w.Header().Set("Retry-After", "604800")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"7-day rate limit exceeded."}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
executor := NewClaudeExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "claude-auth-1",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key",
|
|
"base_url": server.URL,
|
|
},
|
|
}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
ctx := cliproxyexecutor.WithUpstreamAttemptTracker(context.Background())
|
|
_, err := executor.countTokensUpstream(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err == nil {
|
|
t.Fatal("expected error from CountTokens, got nil")
|
|
}
|
|
if !cliproxyexecutor.UpstreamAttempted(ctx) {
|
|
t.Fatal("CountTokens() did not mark the HTTP request as an upstream attempt")
|
|
}
|
|
|
|
var rap retryAfterProvider
|
|
if !errors.As(err, &rap) || rap == nil || rap.RetryAfter() == nil {
|
|
t.Fatalf("expected CountTokens rate limit error to implement RetryAfter, got %v", err)
|
|
}
|
|
|
|
type credentialScopedProvider interface {
|
|
IsCredentialScoped() bool
|
|
}
|
|
var csp credentialScopedProvider
|
|
if !errors.As(err, &csp) || csp == nil || !csp.IsCredentialScoped() {
|
|
t.Fatalf("expected CountTokens rate limit error to be credential-scoped, got %v", err)
|
|
}
|
|
|
|
minExpected := 7*24*time.Hour - 5*time.Second
|
|
maxExpected := 7*24*time.Hour + 35*time.Second
|
|
if *rap.RetryAfter() < minExpected || *rap.RetryAfter() > maxExpected {
|
|
t.Fatalf("RetryAfter = %v, want between %v and %v", *rap.RetryAfter(), minExpected, maxExpected)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_RateLimit_CaseInsensitiveRawHeaderMap(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header()["anthropic-ratelimit-unified-status"] = []string{"rejected"}
|
|
w.Header()["anthropic-ratelimit-unified-7d-status"] = []string{"rejected"}
|
|
w.Header()["anthropic-ratelimit-unified-7d-reset"] = []string{strconv.FormatInt(time.Now().Add(2*time.Hour).Unix(), 10)}
|
|
w.Header()["retry-after"] = []string{"7200"}
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Too many requests."}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
executor := NewClaudeExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "claude-auth-1",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key",
|
|
"base_url": server.URL,
|
|
},
|
|
}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
var rap retryAfterProvider
|
|
if !errors.As(err, &rap) || rap == nil || rap.RetryAfter() == nil {
|
|
t.Fatalf("expected RetryAfter for non-canonical header map, got %v", err)
|
|
}
|
|
|
|
minExpected := 2*time.Hour - 5*time.Second
|
|
maxExpected := 2*time.Hour + 35*time.Second
|
|
if *rap.RetryAfter() < minExpected || *rap.RetryAfter() > maxExpected {
|
|
t.Fatalf("RetryAfter = %v, want between %v and %v", *rap.RetryAfter(), minExpected, maxExpected)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_RateLimit_FastModeAuthoritativeRejectionHeadersOverrideBody(t *testing.T) {
|
|
var attemptsCred1 atomic.Int32
|
|
var attemptsCred2 atomic.Int32
|
|
|
|
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attemptsCred1.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", strconv.FormatInt(time.Now().Add(7*24*time.Hour).Unix(), 10))
|
|
w.Header().Set("Retry-After", "604800")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
// Body text mentioning fast request rejected, but headers explicitly reject unified quota
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Fast request rejected"}}`))
|
|
}))
|
|
defer server1.Close()
|
|
|
|
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attemptsCred2.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"id":"msg-fast-ok","type":"message","role":"assistant","content":[{"type":"text","text":"hello from cred2"}]}`))
|
|
}))
|
|
defer server2.Close()
|
|
|
|
cfg := &config.Config{DisableCooling: false}
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetRetryConfig(0, 0, 2)
|
|
|
|
executor := NewClaudeExecutor(cfg)
|
|
manager.RegisterExecutor(executor)
|
|
|
|
baseID := uuid.NewString()
|
|
auth1 := &cliproxyauth.Auth{ID: baseID + "-fast-override-1", Provider: "claude", Attributes: map[string]string{"api_key": "k1", "base_url": server1.URL}}
|
|
auth2 := &cliproxyauth.Auth{ID: baseID + "-fast-override-2", Provider: "claude", Attributes: map[string]string{"api_key": "k2", "base_url": server2.URL}}
|
|
|
|
reg := registry.GetGlobalRegistry()
|
|
reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{{ID: "claude-3-5-sonnet-20241022"}})
|
|
reg.RegisterClient(auth2.ID, "claude", []*registry.ModelInfo{{ID: "claude-3-5-sonnet-20241022"}})
|
|
t.Cleanup(func() {
|
|
reg.UnregisterClient(auth1.ID)
|
|
reg.UnregisterClient(auth2.ID)
|
|
})
|
|
|
|
if _, err := manager.Register(context.Background(), auth1); err != nil {
|
|
t.Fatalf("register auth1: %v", err)
|
|
}
|
|
if _, err := manager.Register(context.Background(), auth2); err != nil {
|
|
t.Fatalf("register auth2: %v", err)
|
|
}
|
|
|
|
payload := []byte(`{"speed":"fast","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err != nil {
|
|
t.Fatalf("expected failover to cred2 when authoritative rate limit headers present, got: %v", err)
|
|
}
|
|
if len(resp.Payload) == 0 {
|
|
t.Fatal("expected response from cred2")
|
|
}
|
|
|
|
if attemptsCred1.Load() != 1 {
|
|
t.Fatalf("attempts on cred1 = %d, want 1", attemptsCred1.Load())
|
|
}
|
|
if attemptsCred2.Load() != 1 {
|
|
t.Fatalf("attempts on cred2 = %d, want 1", attemptsCred2.Load())
|
|
}
|
|
|
|
// Verify cred1 was cooled down at credential level
|
|
registeredAuth, ok := manager.GetByID(auth1.ID)
|
|
if !ok || registeredAuth == nil {
|
|
t.Fatal("auth1 not found")
|
|
}
|
|
if !registeredAuth.Unavailable || !registeredAuth.Quota.Exceeded {
|
|
t.Fatalf("cred1 was not cooled down: unavailable=%v quota=%+v", registeredAuth.Unavailable, registeredAuth.Quota)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_RateLimit_FastEntitlementWithRetryAfterRemainsRequestScoped(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Retry-After", "120")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Usage credits are required for fast mode."}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{DisableCooling: false}
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetRetryConfig(0, 0, 2)
|
|
|
|
executor := NewClaudeExecutor(cfg)
|
|
manager.RegisterExecutor(executor)
|
|
|
|
baseID := uuid.NewString()
|
|
auth := &cliproxyauth.Auth{ID: baseID + "-fast-entitlement", Provider: "claude", Attributes: map[string]string{"api_key": "k1", "base_url": server.URL}}
|
|
|
|
reg := registry.GetGlobalRegistry()
|
|
reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: "claude-3-5-sonnet-20241022"}})
|
|
t.Cleanup(func() {
|
|
reg.UnregisterClient(auth.ID)
|
|
})
|
|
|
|
if _, err := manager.Register(context.Background(), auth); err != nil {
|
|
t.Fatalf("register auth: %v", err)
|
|
}
|
|
|
|
payload := []byte(`{"speed":"fast","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
_, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
registeredAuth, ok := manager.GetByID(auth.ID)
|
|
if !ok || registeredAuth == nil {
|
|
t.Fatal("auth not found")
|
|
}
|
|
if registeredAuth.Unavailable || registeredAuth.Quota.Exceeded {
|
|
t.Fatalf("fast entitlement refusal incorrectly cooled down the credential: unavailable=%v quota=%+v", registeredAuth.Unavailable, registeredAuth.Quota)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_AuthManager_CredentialScopeBlocksAllModelsAndAliases(t *testing.T) {
|
|
var upstreamAttempts atomic.Int32
|
|
now := time.Now()
|
|
sevenDayReset := now.Add(7 * 24 * time.Hour).Unix()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
upstreamAttempts.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", strconv.FormatInt(sevenDayReset, 10))
|
|
w.Header().Set("Retry-After", strconv.FormatInt(7*24*3600, 10))
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"7d limit rejected."}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
DisableCooling: false,
|
|
}
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetRetryConfig(0, 0, 0)
|
|
|
|
executor := NewClaudeExecutor(cfg)
|
|
manager.RegisterExecutor(executor)
|
|
|
|
baseID := uuid.NewString()
|
|
auth := &cliproxyauth.Auth{
|
|
ID: baseID + "-claude-cred",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key",
|
|
"base_url": server.URL,
|
|
},
|
|
}
|
|
|
|
reg := registry.GetGlobalRegistry()
|
|
reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{
|
|
{ID: "claude-3-5-sonnet-20241022"},
|
|
{ID: "claude-3-opus-20240229"},
|
|
{ID: "claude-3-7-sonnet-20250219"},
|
|
})
|
|
t.Cleanup(func() {
|
|
reg.UnregisterClient(auth.ID)
|
|
})
|
|
|
|
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatalf("failed to register auth: %v", errRegister)
|
|
}
|
|
|
|
// 1. Initial request on sonnet triggers 429 and records 7d cooldown
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
_, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err == nil {
|
|
t.Fatal("expected error on first execute, got nil")
|
|
}
|
|
|
|
if attempts := upstreamAttempts.Load(); attempts != 1 {
|
|
t.Fatalf("upstream attempts = %d, want 1", attempts)
|
|
}
|
|
|
|
// 2. Try requesting a completely different model (opus) on the same credential -> must be blocked locally
|
|
_, errOpus := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-opus-20240229",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errOpus == nil {
|
|
t.Fatal("expected error for opus, got nil")
|
|
}
|
|
if attempts := upstreamAttempts.Load(); attempts != 1 {
|
|
t.Fatalf("upstream attempts after opus = %d, want 1 (must be blocked locally)", attempts)
|
|
}
|
|
|
|
// 3. Try requesting a thinking suffix alias on the same credential -> must also be blocked locally
|
|
_, errThinking := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-7-sonnet-20250219-thinking-16k",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errThinking == nil {
|
|
t.Fatal("expected error for thinking suffix, got nil")
|
|
}
|
|
if attempts := upstreamAttempts.Load(); attempts != 1 {
|
|
t.Fatalf("upstream attempts after thinking suffix = %d, want 1 (must be blocked locally)", attempts)
|
|
}
|
|
|
|
// 4. Try streaming execution for opus on the same cooling credential -> must also be blocked locally
|
|
_, errStream := manager.ExecuteStream(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-opus-20240229",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errStream == nil {
|
|
t.Fatal("expected error for streaming opus, got nil")
|
|
}
|
|
if attempts := upstreamAttempts.Load(); attempts != 1 {
|
|
t.Fatalf("upstream attempts after streaming opus = %d, want 1 (must be blocked locally)", attempts)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_AuthManager_OrdinaryModel429DoesNotBlockSiblingModels(t *testing.T) {
|
|
var attemptsSonnet atomic.Int32
|
|
var attemptsOpus atomic.Int32
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
if strings.Contains(string(body), "claude-3-5-sonnet") {
|
|
attemptsSonnet.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-5h-Status", "allowed")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Model rate limit exceeded."}}`))
|
|
return
|
|
}
|
|
attemptsOpus.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"id":"msg-opus","type":"message","role":"assistant","content":[{"type":"text","text":"hello from opus"}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{DisableCooling: false}
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetRetryConfig(0, 0, 0)
|
|
|
|
executor := NewClaudeExecutor(cfg)
|
|
manager.RegisterExecutor(executor)
|
|
|
|
baseID := uuid.NewString()
|
|
auth := &cliproxyauth.Auth{
|
|
ID: baseID + "-ordinary-429",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key",
|
|
"base_url": server.URL,
|
|
},
|
|
}
|
|
|
|
reg := registry.GetGlobalRegistry()
|
|
reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{
|
|
{ID: "claude-3-5-sonnet-20241022"},
|
|
{ID: "claude-3-opus-20240229"},
|
|
})
|
|
t.Cleanup(func() {
|
|
reg.UnregisterClient(auth.ID)
|
|
})
|
|
|
|
if _, err := manager.Register(context.Background(), auth); err != nil {
|
|
t.Fatalf("register auth: %v", err)
|
|
}
|
|
|
|
// 1. Initial request on sonnet triggers ordinary model 429
|
|
payloadSonnet := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
_, errSonnet := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payloadSonnet,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errSonnet == nil {
|
|
t.Fatal("expected error on sonnet execute, got nil")
|
|
}
|
|
if attemptsSonnet.Load() != 1 {
|
|
t.Fatalf("sonnet attempts = %d, want 1", attemptsSonnet.Load())
|
|
}
|
|
|
|
// 2. Request on opus MUST succeed on the same credential (not blocked by ordinary model-level 429)
|
|
payloadOpus := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi opus"}]}]}`)
|
|
respOpus, errOpus := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-opus-20240229",
|
|
Payload: payloadOpus,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errOpus != nil {
|
|
t.Fatalf("expected opus to succeed on same credential, got error: %v", errOpus)
|
|
}
|
|
if len(respOpus.Payload) == 0 {
|
|
t.Fatal("expected non-empty response for opus")
|
|
}
|
|
if attemptsOpus.Load() != 1 {
|
|
t.Fatalf("opus attempts = %d, want 1", attemptsOpus.Load())
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_AuthManager_AlternativeCredentialCanBeSelected(t *testing.T) {
|
|
var attemptsCred1 atomic.Int32
|
|
var attemptsCred2 atomic.Int32
|
|
now := time.Now()
|
|
sevenDayReset := now.Add(7 * 24 * time.Hour).Unix()
|
|
|
|
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attemptsCred1.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", strconv.FormatInt(sevenDayReset, 10))
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"7d limit rejected."}}`))
|
|
}))
|
|
defer server1.Close()
|
|
|
|
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attemptsCred2.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"id":"msg-123","type":"message","role":"assistant","content":[{"type":"text","text":"hello from cred2"}]}`))
|
|
}))
|
|
defer server2.Close()
|
|
|
|
cfg := &config.Config{
|
|
DisableCooling: false,
|
|
}
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetRetryConfig(0, 0, 2)
|
|
|
|
executor := NewClaudeExecutor(cfg)
|
|
manager.RegisterExecutor(executor)
|
|
|
|
baseID := uuid.NewString()
|
|
auth1 := &cliproxyauth.Auth{
|
|
ID: baseID + "-claude-cred-1",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key-1",
|
|
"base_url": server1.URL,
|
|
},
|
|
}
|
|
auth2 := &cliproxyauth.Auth{
|
|
ID: baseID + "-claude-cred-2",
|
|
Provider: "claude",
|
|
Attributes: map[string]string{
|
|
"api_key": "test-key-2",
|
|
"base_url": server2.URL,
|
|
},
|
|
}
|
|
|
|
reg := registry.GetGlobalRegistry()
|
|
reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{{ID: "claude-3-5-sonnet-20241022"}})
|
|
reg.RegisterClient(auth2.ID, "claude", []*registry.ModelInfo{{ID: "claude-3-5-sonnet-20241022"}})
|
|
t.Cleanup(func() {
|
|
reg.UnregisterClient(auth1.ID)
|
|
reg.UnregisterClient(auth2.ID)
|
|
})
|
|
|
|
if _, err := manager.Register(context.Background(), auth1); err != nil {
|
|
t.Fatalf("register auth1: %v", err)
|
|
}
|
|
if _, err := manager.Register(context.Background(), auth2); err != nil {
|
|
t.Fatalf("register auth2: %v", err)
|
|
}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err != nil {
|
|
t.Fatalf("expected successful failover to cred2, got error: %v", err)
|
|
}
|
|
|
|
if attemptsCred1.Load() != 1 {
|
|
t.Fatalf("attempts on cred1 = %d, want 1", attemptsCred1.Load())
|
|
}
|
|
if attemptsCred2.Load() != 1 {
|
|
t.Fatalf("attempts on cred2 = %d, want 1", attemptsCred2.Load())
|
|
}
|
|
if len(resp.Payload) == 0 {
|
|
t.Fatal("expected non-empty response payload from cred2")
|
|
}
|
|
|
|
// Next request should directly use cred2 without attempting cred1 (which is cooling down)
|
|
resp2, err2 := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-3-5-sonnet-20241022",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err2 != nil {
|
|
t.Fatalf("expected successful request on cred2, got error: %v", err2)
|
|
}
|
|
if attemptsCred1.Load() != 1 {
|
|
t.Fatalf("attempts on cred1 after 2nd request = %d, want 1 (must stay 1)", attemptsCred1.Load())
|
|
}
|
|
if attemptsCred2.Load() != 2 {
|
|
t.Fatalf("attempts on cred2 after 2nd request = %d, want 2", attemptsCred2.Load())
|
|
}
|
|
if len(resp2.Payload) == 0 {
|
|
t.Fatal("expected non-empty response payload from 2nd request")
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_AuthManager_MultiModelPoolStreamStopsProbingOn429(t *testing.T) {
|
|
var attemptsCred1 atomic.Int32
|
|
var attemptsCred2 atomic.Int32
|
|
|
|
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attemptsCred1.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "rejected")
|
|
w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", strconv.FormatInt(time.Now().Add(7*24*time.Hour).Unix(), 10))
|
|
w.Header().Set("Retry-After", "604800")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"rate limited"}}`))
|
|
}))
|
|
defer server1.Close()
|
|
|
|
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attemptsCred2.Add(1)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg-1\",\"model\":\"claude-3-5-sonnet-20241022\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"))
|
|
}))
|
|
defer server2.Close()
|
|
|
|
cfg := &config.Config{DisableCooling: false}
|
|
manager := cliproxyauth.NewManager(nil, nil, nil)
|
|
manager.SetRetryConfig(0, 0, 2)
|
|
|
|
manager.SetOAuthModelAlias(map[string][]config.OAuthModelAlias{
|
|
"claude": {
|
|
{Name: "claude-3-5-sonnet-20241022", Alias: "claude-pool-alias"},
|
|
{Name: "claude-3-opus-20240229", Alias: "claude-pool-alias"},
|
|
},
|
|
})
|
|
|
|
executor := NewClaudeExecutor(cfg)
|
|
manager.RegisterExecutor(executor)
|
|
|
|
baseID := uuid.NewString()
|
|
auth1 := &cliproxyauth.Auth{ID: baseID + "-pool-1", Provider: "claude", Attributes: map[string]string{"api_key": "k1", "base_url": server1.URL}}
|
|
auth2 := &cliproxyauth.Auth{ID: baseID + "-pool-2", Provider: "claude", Attributes: map[string]string{"api_key": "k2", "base_url": server2.URL}}
|
|
|
|
reg := registry.GetGlobalRegistry()
|
|
reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{
|
|
{ID: "claude-pool-alias"},
|
|
{ID: "claude-3-5-sonnet-20241022"},
|
|
{ID: "claude-3-opus-20240229"},
|
|
})
|
|
reg.RegisterClient(auth2.ID, "claude", []*registry.ModelInfo{
|
|
{ID: "claude-pool-alias"},
|
|
{ID: "claude-3-5-sonnet-20241022"},
|
|
{ID: "claude-3-opus-20240229"},
|
|
})
|
|
t.Cleanup(func() {
|
|
reg.UnregisterClient(auth1.ID)
|
|
reg.UnregisterClient(auth2.ID)
|
|
})
|
|
|
|
if _, err := manager.Register(context.Background(), auth1); err != nil {
|
|
t.Fatalf("register auth1: %v", err)
|
|
}
|
|
if _, err := manager.Register(context.Background(), auth2); err != nil {
|
|
t.Fatalf("register auth2: %v", err)
|
|
}
|
|
|
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
res, err := manager.ExecuteStream(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
|
|
Model: "claude-pool-alias",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream failed: %v", err)
|
|
}
|
|
for chunk := range res.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("unexpected chunk error: %v", chunk.Err)
|
|
}
|
|
}
|
|
|
|
// Must have tried cred1 exactly once (did NOT probe the 2nd model on cred1 after 429) and failed over to cred2
|
|
if got := attemptsCred1.Load(); got != 1 {
|
|
t.Fatalf("attempts on cred1 = %d, want 1 (must not probe other models on cooled cred)", got)
|
|
}
|
|
if got := attemptsCred2.Load(); got != 1 {
|
|
t.Fatalf("attempts on cred2 = %d, want 1", got)
|
|
}
|
|
}
|