Skip to content
Merged
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
2 changes: 1 addition & 1 deletion MOBILYZE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# mobilyze-llc/CLIProxyAPI

A fork of [router-for-me/CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) that adds the `soonest-reset` credential selector (MASTRA-765). The selector sends each new session to the account whose weekly window resets soonest, among accounts that still have quota.
A fork of [router-for-me/CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) that adds the `soonest-reset` credential selector (MASTRA-765). The selector sends each new session to the account whose weekly window resets soonest, among accounts that still have quota. New sessions rotate across the accounts of that provider whose weekly resets fall within an hour of the soonest one.

- Base: upstream tag `v8.0.44` (1f917e77), merged into `main` by merge commit `7f7d7a61`.
- To move to a new upstream tag: `git fetch upstream --tags`, create a branch from `main`, and
Expand Down
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ routing:
# quota is used before it expires. Reset times are learned from Claude/Codex response headers.
# A credential with an exhausted window is skipped until that window resets. An unknown Claude
# reset is tried first; an unknown Codex reset last. Other providers behave like fill-first.
# New sessions rotate across credentials whose weekly resets fall within an hour of the soonest.
# Run soonest-reset with session-affinity: true. Without affinity every request is re-ranked,
# so a conversation can move between accounts.
# weighted-round-robin uses each credential's integer weight (default 1, maximum 1,000,000).
Expand Down
40 changes: 36 additions & 4 deletions sdk/cliproxy/auth/selector_soonest_reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package auth
import (
"context"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"time"

cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
Expand All @@ -14,6 +16,7 @@ const (
// weeklyWindowMinutes is the length of the weekly quota window advertised by Codex.
weeklyWindowMinutes = 7 * 24 * 60
week = 7 * 24 * time.Hour
resetGroupTolerance = time.Hour
)

// unknownResetLast orders a credential after every credential with a known reset.
Expand All @@ -31,9 +34,16 @@ var unknownResetLast = time.Unix(1<<62, 0)
// An unknown Claude weekly reset sorts first: the window runs on a fixed per-account
// schedule, so one request learns it. A passed Claude reset rolls forward by whole weeks.
// An unknown or passed Codex weekly reset sorts last: that window is usually unstarted and
// starting it early can waste it. Ties keep input order (ID order), so providers without
// quota signals behave like fill-first.
type SoonestResetSelector struct{}
// starting it early can waste it. Unknown resets keep input order (ID order), so providers
// without quota signals behave like fill-first.
//
// When the soonest credential is not skipped and its weekly reset is known, new picks rotate
// in ID order through the credentials of its provider that are not skipped and whose known
// weekly reset is at most one hour after it. Use it by pointer: it holds the rotation cursor.
type SoonestResetSelector struct {
mu sync.Mutex
cursors map[string]int
}

// Pick selects the available auth whose weekly quota window resets soonest.
func (s *SoonestResetSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
Expand All @@ -53,7 +63,29 @@ func (s *SoonestResetSelector) Pick(ctx context.Context, provider, model string,
picked, pickedGated, pickedReset = auth, gated, resetAt
}
}
return picked, nil
if picked == nil || pickedGated || pickedReset.IsZero() || pickedReset.Equal(unknownResetLast) {
return picked, nil
}
groupProvider := strings.ToLower(strings.TrimSpace(picked.Provider))
var group []*Auth
for _, auth := range available {
if !strings.EqualFold(strings.TrimSpace(auth.Provider), groupProvider) {
continue
}
gated, resetAt := soonestResetRank(auth, now)
if !gated && resetAt.Sub(pickedReset) <= resetGroupTolerance {
group = append(group, auth)
}
}
slices.SortFunc(group, func(a, b *Auth) int { return strings.Compare(a.ID, b.ID) })
s.mu.Lock()
defer s.mu.Unlock()
if s.cursors == nil {
s.cursors = make(map[string]int)
}
cursor := s.cursors[groupProvider]
s.cursors[groupProvider] = cursor + 1
Comment on lines +86 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 High · Provider-wide cursor can starve an independent model pool

The manager filters candidates by model, but all models consume the same provider cursor. With Codex model X served by [a,b] and model Y served by [c], all with equal known resets, alternating fresh sessions gives X cursor values 0,2,4,… and always selects a; b never receives a session. This reproduces through the manager with session affinity enabled, so unrelated model traffic can completely defeat the new load spreading. Also reported for this defect: - Shared provider cursor can starve independent model pools (sdk/cliproxy/auth/selector_soonest_reset.go:87-88): The manager filters candidates by model before calling this shared selector. If Codex accounts [a,b] serve one model and [c,d] serve another, all with equal known future resets, alternating fresh sessions between the models produces a,d,a,d indefinitely. One pool receives only even cursor values and the other only odd values, so b and c are never selected. This reproduces through Manager with session affinity enabled and defeats spreading on normal multi-model traffic.

Also affects:

return group[cursor%len(group)], nil
Comment on lines +87 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical · Signed rotation cursor can overflow and crash selection

The per-provider cursor is an unbounded int. On a 32-bit build, a long-lived process serving 2^31 picks for a provider wraps this value negative; with a rotation group containing at least two credentials, one of the next two calls uses a negative cursor % len(group) as a slice index and panics, crashing the proxy. The cursor must remain bounded and non-negative.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Retry exclusions bias rotation toward later credential IDs

Retries remove already-tried credentials before calling the selector, so applying the accumulated cursor to the shortened group changes the rotation position. For [a,b,c], selecting a then retrying [b,c] selects c and skips b. With five equal-reset credentials and two non-cooling retries per session, a reproduced 50-session sequence yields attempt counts a=20, b=21, c=25, d=34, e=50. This concentrates retry load on later IDs rather than continuing the ID-order rotation, independently of the cross-model cursor issue. Also reported for this defect: - Numeric indexing biases rotation when retries shrink the group (sdk/cliproxy/auth/selector_soonest_reset.go:89): Retry selection excludes already-tried credentials, but the cursor is applied to the newly shortened slice. With one model and five eligible accounts [a,b,c,d,e], selecting a followed by a retry over [b,c,d,e] selects c, skipping b. With transient cooldowns disabled, repeating 50 three-attempt requests yields selection counts a:20, b:21, c:25, d:34, e:50. The tail account receives traffic from every request rather than the eligible accounts being picked in turn. Rotation must preserve its position across retry exclusions.

Also affects:

}

// soonestResetRank reports whether an observed window of auth is exhausted until a future
Expand Down
106 changes: 106 additions & 0 deletions sdk/cliproxy/auth/selector_soonest_reset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"context"
"net/http"
"slices"
"strconv"
"sync"
"testing"
Expand Down Expand Up @@ -423,3 +424,108 @@ func TestSoonestResetSelector_IncomingObservationReplacesOld(t *testing.T) {
t.Fatalf("new session after update = %q, want soonest-incoming-a", got)
}
}

func pickSequence(t *testing.T, selector *SoonestResetSelector, picks int, auths ...*Auth) []string {
t.Helper()
var ids []string
for range picks {
got, err := selector.Pick(context.Background(), "", "", cliproxyexecutor.Options{}, auths)
if err != nil {
t.Fatalf("Pick() error = %v", err)
}
ids = append(ids, got.ID)
}
return ids
}

func assertSequence(t *testing.T, got []string, want ...string) {
t.Helper()
if !slices.Equal(got, want) {
t.Fatalf("picks = %v, want %v", got, want)
}
}

func TestSoonestResetSelectorPick_RotatesWithinResetHour(t *testing.T) {
t.Parallel()

reset := time.Now().Add(2 * day)
assertSequence(t, pickSequence(t, &SoonestResetSelector{}, 4,
codexWeeklyAuth("c", 50, reset.Add(40*time.Minute)),
codexWeeklyAuth("a", 50, reset),
codexWeeklyAuth("b", 50, reset.Add(10*time.Minute)),
), "a", "b", "c", "a")
}

func TestSoonestResetSelectorPick_ResetGroupBoundary(t *testing.T) {
t.Parallel()

reset := time.Now().Add(2 * day)
assertSequence(t, pickSequence(t, &SoonestResetSelector{}, 4,
codexWeeklyAuth("a", 50, reset),
codexWeeklyAuth("b", 50, reset.Add(time.Hour)),
codexWeeklyAuth("c", 50, reset.Add(time.Hour+time.Second)),
), "a", "b", "a", "b")
}

func TestSoonestResetSelectorPick_ResetGroupDoesNotChain(t *testing.T) {
t.Parallel()

reset := time.Now().Add(2 * day)
assertSequence(t, pickSequence(t, &SoonestResetSelector{}, 4,
codexWeeklyAuth("a", 50, reset),
codexWeeklyAuth("b", 50, reset.Add(50*time.Minute)),
codexWeeklyAuth("c", 50, reset.Add(100*time.Minute)),
), "a", "b", "a", "b")
}

func TestSoonestResetSelectorPick_ResetGroupSkipsExhausted(t *testing.T) {
t.Parallel()

reset := time.Now().Add(2 * day)
assertSequence(t, pickSequence(t, &SoonestResetSelector{}, 4,
codexWeeklyAuth("a", 50, reset),
codexWeeklyAuth("b", 100, reset.Add(10*time.Minute)),
codexWeeklyAuth("c", 50, reset.Add(20*time.Minute)),
), "a", "c", "a", "c")
}

func TestSoonestResetSelectorPick_UnknownClaudeResetsKeepOrder(t *testing.T) {
t.Parallel()

assertSequence(t, pickSequence(t, &SoonestResetSelector{}, 3,
&Auth{ID: "a", Provider: "claude"},
&Auth{ID: "b", Provider: "claude"},
&Auth{ID: "c", Provider: "claude"},
), "a", "a", "a")
}

func TestSoonestResetSelectorPick_ResetGroupHoldsWinnerProvider(t *testing.T) {
t.Parallel()

reset := time.Now().Add(2 * day)
assertSequence(t, pickSequence(t, &SoonestResetSelector{}, 4,
claudeAuthResettingAt("a", reset),
codexWeeklyAuth("b", 50, reset.Add(10*time.Minute)),
claudeAuthResettingAt("c", reset.Add(20*time.Minute)),
), "a", "c", "a", "c")
}

func TestSoonestResetSelector_AffinityKeepsSessionWhileNewSessionsRotate(t *testing.T) {
reset := time.Now().Add(2 * day)
manager := newSoonestResetManager(t, nil,
codexWeeklyAuth("soonest-rotate-a", 50, reset),
codexWeeklyAuth("soonest-rotate-b", 50, reset.Add(10*time.Minute)),
codexWeeklyAuth("soonest-rotate-c", 50, reset.Add(20*time.Minute)),
)
first := pickSession(t, manager, "first")
seen := map[string]bool{first: true}
for _, session := range []string{"second", "third"} {
seen[pickSession(t, manager, session)] = true
if got := pickSession(t, manager, "first"); got != first {
t.Fatalf("bound session = %q, want %q", got, first)
}
}
if len(seen) != 3 {
t.Fatalf("new sessions picked %v, want three accounts", seen)
}
}
Loading