diff --git a/MOBILYZE.md b/MOBILYZE.md index f1817697..526e7666 100644 --- a/MOBILYZE.md +++ b/MOBILYZE.md @@ -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 diff --git a/config.example.yaml b/config.example.yaml index f5b9c885..555769e6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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). diff --git a/sdk/cliproxy/auth/selector_soonest_reset.go b/sdk/cliproxy/auth/selector_soonest_reset.go index 79bc525c..3a29e982 100644 --- a/sdk/cliproxy/auth/selector_soonest_reset.go +++ b/sdk/cliproxy/auth/selector_soonest_reset.go @@ -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" @@ -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. @@ -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) { @@ -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 + return group[cursor%len(group)], nil } // soonestResetRank reports whether an observed window of auth is exhausted until a future diff --git a/sdk/cliproxy/auth/selector_soonest_reset_test.go b/sdk/cliproxy/auth/selector_soonest_reset_test.go index 56e211fb..4d307e6f 100644 --- a/sdk/cliproxy/auth/selector_soonest_reset_test.go +++ b/sdk/cliproxy/auth/selector_soonest_reset_test.go @@ -3,6 +3,7 @@ package auth import ( "context" "net/http" + "slices" "strconv" "sync" "testing" @@ -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) + } +}