Skip to content

Commit 12370ee

Browse files
authored
Merge branch 'router-for-me:main' into main
2 parents 5f65dd5 + b84ccc6 commit 12370ee

File tree

7 files changed

+231
-18
lines changed

7 files changed

+231
-18
lines changed

config.example.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ quota-exceeded:
7171
switch-project: true # Whether to automatically switch to another project when a quota is exceeded
7272
switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
7373

74+
# Routing strategy for selecting credentials when multiple match.
75+
routing:
76+
strategy: "round-robin" # round-robin (default), fill-first
77+
7478
# When true, enable authentication for the WebSocket API (/v1/ws).
7579
ws-auth: false
7680

internal/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ type Config struct {
6060
// QuotaExceeded defines the behavior when a quota is exceeded.
6161
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
6262

63+
// Routing controls credential selection behavior.
64+
Routing RoutingConfig `yaml:"routing" json:"routing"`
65+
6366
// WebsocketAuth enables or disables authentication for the WebSocket API.
6467
WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
6568

@@ -136,6 +139,13 @@ type QuotaExceeded struct {
136139
SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"`
137140
}
138141

142+
// RoutingConfig configures how credentials are selected for requests.
143+
type RoutingConfig struct {
144+
// Strategy selects the credential selection strategy.
145+
// Supported values: "round-robin" (default), "fill-first".
146+
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`
147+
}
148+
139149
// AmpModelMapping defines a model name mapping for Amp CLI requests.
140150
// When Amp requests a model that isn't available locally, this mapping
141151
// allows routing to an alternative model that IS available.

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: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ type RoundRobinSelector struct {
2020
cursors map[string]int
2121
}
2222

23+
// FillFirstSelector selects the first available credential (deterministic ordering).
24+
// This "burns" one account before moving to the next, which can help stagger
25+
// rolling-window subscription caps (e.g. chat message limits).
26+
type FillFirstSelector struct{}
27+
2328
type blockReason int
2429

2530
const (
@@ -98,20 +103,8 @@ func (e *modelCooldownError) Headers() http.Header {
98103
return headers
99104
}
100105

101-
// Pick selects the next available auth for the provider in a round-robin manner.
102-
func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
103-
_ = ctx
104-
_ = opts
105-
if len(auths) == 0 {
106-
return nil, &Error{Code: "auth_not_found", Message: "no auth candidates"}
107-
}
108-
if s.cursors == nil {
109-
s.cursors = make(map[string]int)
110-
}
111-
available := make([]*Auth, 0, len(auths))
112-
now := time.Now()
113-
cooldownCount := 0
114-
var earliest time.Time
106+
func collectAvailable(auths []*Auth, model string, now time.Time) (available []*Auth, cooldownCount int, earliest time.Time) {
107+
available = make([]*Auth, 0, len(auths))
115108
for i := 0; i < len(auths); i++ {
116109
candidate := auths[i]
117110
blocked, reason, next := isAuthBlockedForModel(candidate, model, now)
@@ -126,6 +119,18 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o
126119
}
127120
}
128121
}
122+
if len(available) > 1 {
123+
sort.Slice(available, func(i, j int) bool { return available[i].ID < available[j].ID })
124+
}
125+
return available, cooldownCount, earliest
126+
}
127+
128+
func getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([]*Auth, error) {
129+
if len(auths) == 0 {
130+
return nil, &Error{Code: "auth_not_found", Message: "no auth candidates"}
131+
}
132+
133+
available, cooldownCount, earliest := collectAvailable(auths, model, now)
129134
if len(available) == 0 {
130135
if cooldownCount == len(auths) && !earliest.IsZero() {
131136
resetIn := earliest.Sub(now)
@@ -136,12 +141,24 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o
136141
}
137142
return nil, &Error{Code: "auth_unavailable", Message: "no auth available"}
138143
}
139-
// Make round-robin deterministic even if caller's candidate order is unstable.
140-
if len(available) > 1 {
141-
sort.Slice(available, func(i, j int) bool { return available[i].ID < available[j].ID })
144+
145+
return available, nil
146+
}
147+
148+
// Pick selects the next available auth for the provider in a round-robin manner.
149+
func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
150+
_ = ctx
151+
_ = opts
152+
now := time.Now()
153+
available, err := getAvailableAuths(auths, provider, model, now)
154+
if err != nil {
155+
return nil, err
142156
}
143157
key := provider + ":" + model
144158
s.mu.Lock()
159+
if s.cursors == nil {
160+
s.cursors = make(map[string]int)
161+
}
145162
index := s.cursors[key]
146163

147164
if index >= 2_147_483_640 {
@@ -154,6 +171,18 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o
154171
return available[index%len(available)], nil
155172
}
156173

174+
// Pick selects the first available auth for the provider in a deterministic manner.
175+
func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
176+
_ = ctx
177+
_ = opts
178+
now := time.Now()
179+
available, err := getAvailableAuths(auths, provider, model, now)
180+
if err != nil {
181+
return nil, err
182+
}
183+
return available[0], nil
184+
}
185+
157186
func isAuthBlockedForModel(auth *Auth, model string, now time.Time) (bool, blockReason, time.Time) {
158187
if auth == nil {
159188
return true, blockReasonOther, time.Time{}

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/builder.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package cliproxy
55

66
import (
77
"fmt"
8+
"strings"
89

910
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
1011
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
@@ -197,7 +198,20 @@ func (b *Builder) Build() (*Service, error) {
197198
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok && b.cfg != nil {
198199
dirSetter.SetBaseDir(b.cfg.AuthDir)
199200
}
200-
coreManager = coreauth.NewManager(tokenStore, nil, nil)
201+
202+
strategy := ""
203+
if b.cfg != nil {
204+
strategy = strings.ToLower(strings.TrimSpace(b.cfg.Routing.Strategy))
205+
}
206+
var selector coreauth.Selector
207+
switch strategy {
208+
case "fill-first", "fillfirst", "ff":
209+
selector = &coreauth.FillFirstSelector{}
210+
default:
211+
selector = &coreauth.RoundRobinSelector{}
212+
}
213+
214+
coreManager = coreauth.NewManager(tokenStore, selector, nil)
201215
}
202216
// Attach a default RoundTripper provider so providers can opt-in per-auth transports.
203217
coreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())

sdk/cliproxy/service.go

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

511511
var watcherWrapper *WatcherWrapper
512512
reloadCallback := func(newCfg *config.Config) {
513+
previousStrategy := ""
514+
s.cfgMu.RLock()
515+
if s.cfg != nil {
516+
previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy))
517+
}
518+
s.cfgMu.RUnlock()
519+
513520
if newCfg == nil {
514521
s.cfgMu.RLock()
515522
newCfg = s.cfg
@@ -518,6 +525,30 @@ func (s *Service) Run(ctx context.Context) error {
518525
if newCfg == nil {
519526
return
520527
}
528+
529+
nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy))
530+
normalizeStrategy := func(strategy string) string {
531+
switch strategy {
532+
case "fill-first", "fillfirst", "ff":
533+
return "fill-first"
534+
default:
535+
return "round-robin"
536+
}
537+
}
538+
previousStrategy = normalizeStrategy(previousStrategy)
539+
nextStrategy = normalizeStrategy(nextStrategy)
540+
if s.coreManager != nil && previousStrategy != nextStrategy {
541+
var selector coreauth.Selector
542+
switch nextStrategy {
543+
case "fill-first":
544+
selector = &coreauth.FillFirstSelector{}
545+
default:
546+
selector = &coreauth.RoundRobinSelector{}
547+
}
548+
s.coreManager.SetSelector(selector)
549+
log.Infof("routing strategy updated to %s", nextStrategy)
550+
}
551+
521552
s.applyRetryConfig(newCfg)
522553
if s.server != nil {
523554
s.server.UpdateClients(newCfg)

0 commit comments

Comments
 (0)