Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 165 additions & 138 deletions sdk/cliproxy/auth/selector.go

Large diffs are not rendered by default.

72 changes: 0 additions & 72 deletions sdk/cliproxy/auth/selector_review_p2_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
package auth

import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"testing"
"time"

cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)

// Regression tests mirrored from CLIProxyAPI PR #4881 follow-up
Expand Down Expand Up @@ -98,75 +95,6 @@ func TestGetAvailableAuthsSkipsNilCandidates(t *testing.T) {
}
}

// TestPickRebindsSplitAffinityGroupsOnFailover mirrors the CPA regression
// guard for the codex P2 finding on PR #4881. The CPAPlus binding design has
// no splitConflict skip: on a miss it rebinds the observed stale group via
// CompareAndReplaceAliases and absorbs both session keys into it, which
// converges the split groups onto the selected auth. This test locks that
// convergence in.
func TestPickRebindsSplitAffinityGroupsOnFailover(t *testing.T) {
t.Parallel()

model := "test-model"
provider := "gemini"
primaryKey := provider + "::pck:pk1::" + model
fallbackKey := provider + "::conv:c1::" + model

cooled := func(id string) *Auth {
return &Auth{
ID: id,
ModelStates: map[string]*ModelState{
model: {
Status: StatusActive,
Unavailable: true,
NextRetryAfter: time.Now().Add(60 * time.Second),
Quota: QuotaState{
Exceeded: true,
NextRecoverAt: time.Now().Add(60 * time.Second),
},
},
},
}
}
authA := cooled("auth-a")
authB := cooled("auth-b")
authC := &Auth{
ID: "auth-c",
ModelStates: map[string]*ModelState{
model: {Status: StatusActive},
},
}

selector := NewSessionAffinitySelector(&FillFirstSelector{})
selector.cache.SetAliases("auth-a", primaryKey)
selector.cache.SetAliases("auth-b", fallbackKey)

payload := []byte(`{"prompt_cache_key":"pk1","conversation":{"id":"c1"}}`)
opts := cliproxyexecutor.Options{OriginalRequest: payload, Metadata: map[string]any{}}
auth, err := selector.Pick(context.Background(), provider, model, opts, []*Auth{authA, authB, authC})
if err != nil {
t.Fatalf("Pick() error = %v, want nil", err)
}
if auth != authC {
t.Fatalf("Pick() = %v, want auth-c (only available auth)", auth.ID)
}

gotPrimary, genP, aliasesPrimary, okPrimary := selector.cache.GetWithGeneration(primaryKey)
if !okPrimary || gotPrimary != "auth-c" {
t.Fatalf("primary group after failover = %q (ok=%v), want auth-c", gotPrimary, okPrimary)
}
gotFallback, genF, _, okFallback := selector.cache.GetWithGeneration(fallbackKey)
if !okFallback || gotFallback != "auth-c" {
t.Fatalf("fallback group after failover = %q (ok=%v), want auth-c", gotFallback, okFallback)
}
if genP == 0 || genP != genF {
t.Fatalf("split groups not merged into one: primary gen=%d, fallback gen=%d", genP, genF)
}
if !slices.Contains(aliasesPrimary, fallbackKey) {
t.Fatalf("primary group aliases %v missing fallback key %q", aliasesPrimary, fallbackKey)
}
}

// TestCompareAndDeleteGroupRejectsStaleObservation covers the codex P2
// follow-up on PR #4881: when a concurrent request refreshes or extends the
// fallback group between the observation and the delete, a stale merge
Expand Down
91 changes: 39 additions & 52 deletions sdk/cliproxy/auth/selector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,7 @@ func TestSessionAffinitySelector_ThinkingSuffixVariantsPreserveBindingAndRelease
t.Fatalf("third Pick() auth.ID = %q, want %q (thinking suffix variant should keep session stickiness)", third.ID, first.ID)
}

// Failure on a thinking-suffix variant (with explicit metadata) should properly release the session binding
// Terminal failure on a thinking-suffix variant (with explicit metadata) should properly release the session binding
optsWithMetadata := cliproxyexecutor.Options{
OriginalRequest: payload,
Metadata: map[string]any{
Expand All @@ -835,7 +835,7 @@ func TestSessionAffinitySelector_ThinkingSuffixVariantsPreserveBindingAndRelease
Model: "claude-sonnet-4-5(high)",
AuthID: first.ID,
Success: false,
Error: &Error{Code: "rate_limited", Message: "rate limited"},
Error: &Error{Code: "unauthorized", HTTPStatus: http.StatusUnauthorized, Message: "invalid api key"},
Options: optsWithMetadata,
})

Expand Down Expand Up @@ -864,7 +864,6 @@ func TestSessionAffinitySelector_WeightedBindingRebindsAfterWeightBecomesZero(t
if errFirst != nil {
t.Fatalf("first Pick() error = %v", errFirst)
}
selector.OnResult(Result{AuthID: first.ID, Provider: "claude", Model: "claude-3", Options: opts, Success: true})
if first.ID != authA.ID {
t.Fatalf("first Pick() auth.ID = %q, want %q", first.ID, authA.ID)
}
Expand All @@ -874,18 +873,28 @@ func TestSessionAffinitySelector_WeightedBindingRebindsAfterWeightBecomesZero(t
if errSecond != nil {
t.Fatalf("Pick() after weight update error = %v", errSecond)
}
selector.OnResult(Result{AuthID: second.ID, Provider: "claude", Model: "claude-3", Options: opts, Success: true})
if second.ID != authB.ID {
t.Fatalf("Pick() after weight update auth.ID = %q, want %q", second.ID, authB.ID)
}

authA.Attributes[AttributeWeight] = "10"
third, errThird := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
if errThird != nil {
t.Fatalf("Pick() after rebind error = %v", errThird)
t.Fatalf("Pick() after weight restored error = %v", errThird)
}
if third.ID != authA.ID {
t.Fatalf("Pick() after weight restored auth.ID = %q, want original bound auth %q", third.ID, authA.ID)
}

// When authA is invalidated while remaining weight 0, session permanently rebinds to authB
authA.Attributes[AttributeWeight] = "0"
selector.InvalidateAuth(authA.ID)
fourth, errFourth := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
if errFourth != nil {
t.Fatalf("Pick() after invalidation error = %v", errFourth)
}
if third.ID != authB.ID {
t.Fatalf("Pick() after rebind auth.ID = %q, want sticky auth %q", third.ID, authB.ID)
if fourth.ID != authB.ID {
t.Fatalf("Pick() after invalidation auth.ID = %q, want %q", fourth.ID, authB.ID)
}
}

Expand Down Expand Up @@ -1008,31 +1017,46 @@ func TestSessionAffinitySelector_FailoverWhenAuthUnavailable(t *testing.T) {
if err != nil {
t.Fatalf("Pick() error = %v", err)
}
selector.OnResult(Result{AuthID: first.ID, Provider: "claude", Model: "claude-3", Options: opts, Success: true})

// Remove the bound auth from available list (simulating rate limit)
// Remove the bound auth from available list (simulating rate limit / transient exclusion)
availableWithoutFirst := make([]*Auth, 0, len(auths)-1)
for _, a := range auths {
if a.ID != first.ID {
availableWithoutFirst = append(availableWithoutFirst, a)
}
}

// With failover enabled, should pick a new auth
// While original auth is temporarily unavailable, should pick a fallback auth
second, err := selector.Pick(context.Background(), "claude", "claude-3", opts, availableWithoutFirst)
if err != nil {
t.Fatalf("Pick() after failover error = %v", err)
}
if second.ID == first.ID {
t.Fatalf("Pick() after failover returned same auth %q, expected different", first.ID)
}
selector.OnResult(Result{AuthID: second.ID, Provider: "claude", Model: "claude-3", Options: opts, Success: true})

// Subsequent picks should consistently return the new binding
// When original bound auth becomes available again, affinity is retained (returns to warm cache)
recovered, errRecovered := selector.Pick(context.Background(), "claude", "claude-3", opts, auths)
if errRecovered != nil {
t.Fatalf("Pick() after recovery error = %v", errRecovered)
}
if recovered.ID != first.ID {
t.Fatalf("Pick() after recovery = %q, want original bound auth %q", recovered.ID, first.ID)
}

// When original auth is explicitly invalidated (permanent failover), session rebinds
selector.InvalidateAuth(first.ID)
third, errThird := selector.Pick(context.Background(), "claude", "claude-3", opts, availableWithoutFirst)
if errThird != nil {
t.Fatalf("Pick() after invalidation error = %v", errThird)
}
if third.ID == first.ID {
t.Fatalf("Pick() after invalidation returned invalidated auth %q", first.ID)
}
for i := 0; i < 5; i++ {
got, _ := selector.Pick(context.Background(), "claude", "claude-3", opts, availableWithoutFirst)
if got.ID != second.ID {
t.Fatalf("Pick() #%d after failover inconsistent: got %q, want %q", i, got.ID, second.ID)
if got.ID != third.ID {
t.Fatalf("Pick() #%d after permanent rebind inconsistent: got %q, want %q", i, got.ID, third.ID)
}
}
}
Expand Down Expand Up @@ -1562,43 +1586,6 @@ func TestSessionAffinitySelectorCombinedIdentifiersBindConversationFallback(t *t
}
}

func TestSessionAffinitySelectorFailureQuarantinesAllAliases(t *testing.T) {
selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
Fallback: &RoundRobinSelector{},
TTL: time.Minute,
})
defer selector.Stop()
auths := []*Auth{{ID: "auth-a"}, {ID: "auth-b"}}
provider := "responses-alias-group-failure"
model := "gpt-test"

combined := cliproxyexecutor.Options{OriginalRequest: []byte(`{"conversation":{"id":"conversation-session"},"prompt_cache_key":"shared-cache-bucket"}`), Metadata: map[string]any{}}
first, err := selector.Pick(context.Background(), provider, model, combined, auths)
if err != nil {
t.Fatalf("combined-identifier Pick() error = %v", err)
}
selector.OnResult(Result{AuthID: first.ID, Provider: provider, Model: model, Options: combined, Success: true})

promptOnly := cliproxyexecutor.Options{OriginalRequest: []byte(`{"prompt_cache_key":"shared-cache-bucket"}`), Metadata: map[string]any{}}
failed, err := selector.Pick(context.Background(), provider, model, promptOnly, auths)
if err != nil {
t.Fatalf("prompt-only Pick() error = %v", err)
}
if failed.ID != first.ID {
t.Fatalf("prompt-only alias selected %q, want %q", failed.ID, first.ID)
}
selector.OnResult(Result{AuthID: failed.ID, Provider: provider, Model: model, Options: promptOnly, Error: &Error{Code: "upstream_failed", Message: "upstream failed", Retryable: true}})

conversationOnly := cliproxyexecutor.Options{OriginalRequest: []byte(`{"conversation":{"id":"conversation-session"}}`), Metadata: map[string]any{}}
next, err := selector.Pick(context.Background(), provider, model, conversationOnly, auths)
if err != nil {
t.Fatalf("conversation-only Pick() error = %v", err)
}
if next.ID == failed.ID {
t.Fatalf("conversation alias reused failed auth %q", failed.ID)
}
}

func TestSessionCacheCompareAndReplaceAliasesPreservesNewerBinding(t *testing.T) {
cache := NewSessionCache(time.Minute)
defer cache.Stop()
Expand Down Expand Up @@ -2857,7 +2844,7 @@ func TestSessionAffinitySelector_FallbackReselectReceivesOnlyAvailable(t *testin
// threads the request-scoped set of failed auth IDs through to the selector so a
// failed auth is never re-picked for the remainder of that request, even though
// it is still locally "available".
func TestSessionAffinitySelector_RequestScopedExclusionBreaksCarousel(t *testing.T) {
func TestSessionAffinitySelector_RequestScopedExclusionBreaksCarouselWithRecording(t *testing.T) {
t.Parallel()

rec := &recordingFallbackSelector{inner: &RoundRobinSelector{}}
Expand Down
Loading
Loading