Skip to content

Commit b84ccc6

Browse files
committed
feat: add unit tests for routing strategies and implement dynamic selector updates
Added comprehensive tests for `FillFirstSelector` and `RoundRobinSelector` to ensure proper behavior, including deterministic, cyclical, and concurrent scenarios. Introduced dynamic routing strategy updates in `service.go`, normalizing strategies and seamlessly switching between `fill-first` and `round-robin`. Updated `Manager` to support selector changes via the new `SetSelector` method.
1 parent e19ddb5 commit b84ccc6

File tree

4 files changed

+159
-3
lines changed

4 files changed

+159
-3
lines changed

sdk/cliproxy/auth/manager.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,18 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager {
135135
}
136136
}
137137

138+
func (m *Manager) SetSelector(selector Selector) {
139+
if m == nil {
140+
return
141+
}
142+
if selector == nil {
143+
selector = &RoundRobinSelector{}
144+
}
145+
m.mu.Lock()
146+
m.selector = selector
147+
m.mu.Unlock()
148+
}
149+
138150
// SetStore swaps the underlying persistence store.
139151
func (m *Manager) SetStore(store Store) {
140152
m.mu.Lock()

sdk/cliproxy/auth/selector.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,16 @@ func getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([]
149149
func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
150150
_ = ctx
151151
_ = opts
152-
if s.cursors == nil {
153-
s.cursors = make(map[string]int)
154-
}
155152
now := time.Now()
156153
available, err := getAvailableAuths(auths, provider, model, now)
157154
if err != nil {
158155
return nil, err
159156
}
160157
key := provider + ":" + model
161158
s.mu.Lock()
159+
if s.cursors == nil {
160+
s.cursors = make(map[string]int)
161+
}
162162
index := s.cursors[key]
163163

164164
if index >= 2_147_483_640 {

sdk/cliproxy/auth/selector_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
"testing"
8+
9+
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
10+
)
11+
12+
func TestFillFirstSelectorPick_Deterministic(t *testing.T) {
13+
t.Parallel()
14+
15+
selector := &FillFirstSelector{}
16+
auths := []*Auth{
17+
{ID: "b"},
18+
{ID: "a"},
19+
{ID: "c"},
20+
}
21+
22+
got, err := selector.Pick(context.Background(), "gemini", "", cliproxyexecutor.Options{}, auths)
23+
if err != nil {
24+
t.Fatalf("Pick() error = %v", err)
25+
}
26+
if got == nil {
27+
t.Fatalf("Pick() auth = nil")
28+
}
29+
if got.ID != "a" {
30+
t.Fatalf("Pick() auth.ID = %q, want %q", got.ID, "a")
31+
}
32+
}
33+
34+
func TestRoundRobinSelectorPick_CyclesDeterministic(t *testing.T) {
35+
t.Parallel()
36+
37+
selector := &RoundRobinSelector{}
38+
auths := []*Auth{
39+
{ID: "b"},
40+
{ID: "a"},
41+
{ID: "c"},
42+
}
43+
44+
want := []string{"a", "b", "c", "a", "b"}
45+
for i, id := range want {
46+
got, err := selector.Pick(context.Background(), "gemini", "", cliproxyexecutor.Options{}, auths)
47+
if err != nil {
48+
t.Fatalf("Pick() #%d error = %v", i, err)
49+
}
50+
if got == nil {
51+
t.Fatalf("Pick() #%d auth = nil", i)
52+
}
53+
if got.ID != id {
54+
t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, id)
55+
}
56+
}
57+
}
58+
59+
func TestRoundRobinSelectorPick_Concurrent(t *testing.T) {
60+
selector := &RoundRobinSelector{}
61+
auths := []*Auth{
62+
{ID: "b"},
63+
{ID: "a"},
64+
{ID: "c"},
65+
}
66+
67+
start := make(chan struct{})
68+
var wg sync.WaitGroup
69+
errCh := make(chan error, 1)
70+
71+
goroutines := 32
72+
iterations := 100
73+
for i := 0; i < goroutines; i++ {
74+
wg.Add(1)
75+
go func() {
76+
defer wg.Done()
77+
<-start
78+
for j := 0; j < iterations; j++ {
79+
got, err := selector.Pick(context.Background(), "gemini", "", cliproxyexecutor.Options{}, auths)
80+
if err != nil {
81+
select {
82+
case errCh <- err:
83+
default:
84+
}
85+
return
86+
}
87+
if got == nil {
88+
select {
89+
case errCh <- errors.New("Pick() returned nil auth"):
90+
default:
91+
}
92+
return
93+
}
94+
if got.ID == "" {
95+
select {
96+
case errCh <- errors.New("Pick() returned auth with empty ID"):
97+
default:
98+
}
99+
return
100+
}
101+
}
102+
}()
103+
}
104+
105+
close(start)
106+
wg.Wait()
107+
108+
select {
109+
case err := <-errCh:
110+
t.Fatalf("concurrent Pick() error = %v", err)
111+
default:
112+
}
113+
}

sdk/cliproxy/service.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,13 @@ func (s *Service) Run(ctx context.Context) error {
506506

507507
var watcherWrapper *WatcherWrapper
508508
reloadCallback := func(newCfg *config.Config) {
509+
previousStrategy := ""
510+
s.cfgMu.RLock()
511+
if s.cfg != nil {
512+
previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy))
513+
}
514+
s.cfgMu.RUnlock()
515+
509516
if newCfg == nil {
510517
s.cfgMu.RLock()
511518
newCfg = s.cfg
@@ -514,6 +521,30 @@ func (s *Service) Run(ctx context.Context) error {
514521
if newCfg == nil {
515522
return
516523
}
524+
525+
nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy))
526+
normalizeStrategy := func(strategy string) string {
527+
switch strategy {
528+
case "fill-first", "fillfirst", "ff":
529+
return "fill-first"
530+
default:
531+
return "round-robin"
532+
}
533+
}
534+
previousStrategy = normalizeStrategy(previousStrategy)
535+
nextStrategy = normalizeStrategy(nextStrategy)
536+
if s.coreManager != nil && previousStrategy != nextStrategy {
537+
var selector coreauth.Selector
538+
switch nextStrategy {
539+
case "fill-first":
540+
selector = &coreauth.FillFirstSelector{}
541+
default:
542+
selector = &coreauth.RoundRobinSelector{}
543+
}
544+
s.coreManager.SetSelector(selector)
545+
log.Infof("routing strategy updated to %s", nextStrategy)
546+
}
547+
517548
s.applyRetryConfig(newCfg)
518549
if s.server != nil {
519550
s.server.UpdateClients(newCfg)

0 commit comments

Comments
 (0)