Skip to content

Commit d33f68e

Browse files
authored
Merge pull request router-for-me#1663 from rensumo/main
feat: implement credential-based round-robin for gemini-cli
2 parents afb5db2 + 7e9b141 commit d33f68e

File tree

2 files changed

+200
-5
lines changed

2 files changed

+200
-5
lines changed

sdk/cliproxy/auth/selector.go

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"math"
8+
"math/rand/v2"
89
"net/http"
910
"sort"
1011
"strconv"
@@ -248,6 +249,9 @@ func getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([]
248249
}
249250

250251
// Pick selects the next available auth for the provider in a round-robin manner.
252+
// For gemini-cli virtual auths (identified by the gemini_virtual_parent attribute),
253+
// a two-level round-robin is used: first cycling across credential groups (parent
254+
// accounts), then cycling within each group's project auths.
251255
func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
252256
_ = opts
253257
now := time.Now()
@@ -265,21 +269,87 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o
265269
if limit <= 0 {
266270
limit = 4096
267271
}
268-
if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit {
269-
s.cursors = make(map[string]int)
272+
273+
// Check if any available auth has gemini_virtual_parent attribute,
274+
// indicating gemini-cli virtual auths that should use credential-level polling.
275+
groups, parentOrder := groupByVirtualParent(available)
276+
if len(parentOrder) > 1 {
277+
// Two-level round-robin: first select a credential group, then pick within it.
278+
groupKey := key + "::group"
279+
s.ensureCursorKey(groupKey, limit)
280+
if _, exists := s.cursors[groupKey]; !exists {
281+
// Seed with a random initial offset so the starting credential is randomized.
282+
s.cursors[groupKey] = rand.IntN(len(parentOrder))
283+
}
284+
groupIndex := s.cursors[groupKey]
285+
if groupIndex >= 2_147_483_640 {
286+
groupIndex = 0
287+
}
288+
s.cursors[groupKey] = groupIndex + 1
289+
290+
selectedParent := parentOrder[groupIndex%len(parentOrder)]
291+
group := groups[selectedParent]
292+
293+
// Second level: round-robin within the selected credential group.
294+
innerKey := key + "::cred:" + selectedParent
295+
s.ensureCursorKey(innerKey, limit)
296+
innerIndex := s.cursors[innerKey]
297+
if innerIndex >= 2_147_483_640 {
298+
innerIndex = 0
299+
}
300+
s.cursors[innerKey] = innerIndex + 1
301+
s.mu.Unlock()
302+
return group[innerIndex%len(group)], nil
270303
}
271-
index := s.cursors[key]
272304

305+
// Flat round-robin for non-grouped auths (original behavior).
306+
s.ensureCursorKey(key, limit)
307+
index := s.cursors[key]
273308
if index >= 2_147_483_640 {
274309
index = 0
275310
}
276-
277311
s.cursors[key] = index + 1
278312
s.mu.Unlock()
279-
// log.Debugf("available: %d, index: %d, key: %d", len(available), index, index%len(available))
280313
return available[index%len(available)], nil
281314
}
282315

316+
// ensureCursorKey ensures the cursor map has capacity for the given key.
317+
// Must be called with s.mu held.
318+
func (s *RoundRobinSelector) ensureCursorKey(key string, limit int) {
319+
if _, ok := s.cursors[key]; !ok && len(s.cursors) >= limit {
320+
s.cursors = make(map[string]int)
321+
}
322+
}
323+
324+
// groupByVirtualParent groups auths by their gemini_virtual_parent attribute.
325+
// Returns a map of parentID -> auths and a sorted slice of parent IDs for stable iteration.
326+
// Only auths with a non-empty gemini_virtual_parent are grouped; if any auth lacks
327+
// this attribute, nil/nil is returned so the caller falls back to flat round-robin.
328+
func groupByVirtualParent(auths []*Auth) (map[string][]*Auth, []string) {
329+
if len(auths) == 0 {
330+
return nil, nil
331+
}
332+
groups := make(map[string][]*Auth)
333+
for _, a := range auths {
334+
parent := ""
335+
if a.Attributes != nil {
336+
parent = strings.TrimSpace(a.Attributes["gemini_virtual_parent"])
337+
}
338+
if parent == "" {
339+
// Non-virtual auth present; fall back to flat round-robin.
340+
return nil, nil
341+
}
342+
groups[parent] = append(groups[parent], a)
343+
}
344+
// Collect parent IDs in sorted order for stable cursor indexing.
345+
parentOrder := make([]string, 0, len(groups))
346+
for p := range groups {
347+
parentOrder = append(parentOrder, p)
348+
}
349+
sort.Strings(parentOrder)
350+
return groups, parentOrder
351+
}
352+
283353
// Pick selects the first available auth for the provider in a deterministic manner.
284354
func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
285355
_ = opts

sdk/cliproxy/auth/selector_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,128 @@ func TestRoundRobinSelectorPick_CursorKeyCap(t *testing.T) {
402402
t.Fatalf("selector.cursors missing key %q", "gemini:m3")
403403
}
404404
}
405+
406+
func TestRoundRobinSelectorPick_GeminiCLICredentialGrouping(t *testing.T) {
407+
t.Parallel()
408+
409+
selector := &RoundRobinSelector{}
410+
411+
// Simulate two gemini-cli credentials, each with multiple projects:
412+
// Credential A (parent = "cred-a.json") has 3 projects
413+
// Credential B (parent = "cred-b.json") has 2 projects
414+
auths := []*Auth{
415+
{ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
416+
{ID: "cred-a.json::proj-a2", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
417+
{ID: "cred-a.json::proj-a3", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
418+
{ID: "cred-b.json::proj-b1", Attributes: map[string]string{"gemini_virtual_parent": "cred-b.json"}},
419+
{ID: "cred-b.json::proj-b2", Attributes: map[string]string{"gemini_virtual_parent": "cred-b.json"}},
420+
}
421+
422+
// Two-level round-robin: consecutive picks must alternate between credentials.
423+
// Credential group order is randomized, but within each call the group cursor
424+
// advances by 1, so consecutive picks should cycle through different parents.
425+
picks := make([]string, 6)
426+
parents := make([]string, 6)
427+
for i := 0; i < 6; i++ {
428+
got, err := selector.Pick(context.Background(), "gemini-cli", "gemini-2.5-pro", cliproxyexecutor.Options{}, auths)
429+
if err != nil {
430+
t.Fatalf("Pick() #%d error = %v", i, err)
431+
}
432+
if got == nil {
433+
t.Fatalf("Pick() #%d auth = nil", i)
434+
}
435+
picks[i] = got.ID
436+
parents[i] = got.Attributes["gemini_virtual_parent"]
437+
}
438+
439+
// Verify property: consecutive picks must alternate between credential groups.
440+
for i := 1; i < len(parents); i++ {
441+
if parents[i] == parents[i-1] {
442+
t.Fatalf("Pick() #%d and #%d both from same parent %q (IDs: %q, %q); expected alternating credentials",
443+
i-1, i, parents[i], picks[i-1], picks[i])
444+
}
445+
}
446+
447+
// Verify property: each credential's projects are picked in sequence (round-robin within group).
448+
credPicks := map[string][]string{}
449+
for i, id := range picks {
450+
credPicks[parents[i]] = append(credPicks[parents[i]], id)
451+
}
452+
for parent, ids := range credPicks {
453+
for i := 1; i < len(ids); i++ {
454+
if ids[i] == ids[i-1] {
455+
t.Fatalf("Credential %q picked same project %q twice in a row", parent, ids[i])
456+
}
457+
}
458+
}
459+
}
460+
461+
func TestRoundRobinSelectorPick_SingleParentFallsBackToFlat(t *testing.T) {
462+
t.Parallel()
463+
464+
selector := &RoundRobinSelector{}
465+
466+
// All auths from the same parent - should fall back to flat round-robin
467+
// because there's only one credential group (no benefit from two-level).
468+
auths := []*Auth{
469+
{ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
470+
{ID: "cred-a.json::proj-a2", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
471+
{ID: "cred-a.json::proj-a3", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
472+
}
473+
474+
// With single parent group, parentOrder has length 1, so it uses flat round-robin.
475+
// Sorted by ID: proj-a1, proj-a2, proj-a3
476+
want := []string{
477+
"cred-a.json::proj-a1",
478+
"cred-a.json::proj-a2",
479+
"cred-a.json::proj-a3",
480+
"cred-a.json::proj-a1",
481+
}
482+
483+
for i, expectedID := range want {
484+
got, err := selector.Pick(context.Background(), "gemini-cli", "gemini-2.5-pro", cliproxyexecutor.Options{}, auths)
485+
if err != nil {
486+
t.Fatalf("Pick() #%d error = %v", i, err)
487+
}
488+
if got == nil {
489+
t.Fatalf("Pick() #%d auth = nil", i)
490+
}
491+
if got.ID != expectedID {
492+
t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, expectedID)
493+
}
494+
}
495+
}
496+
497+
func TestRoundRobinSelectorPick_MixedVirtualAndNonVirtualFallsBackToFlat(t *testing.T) {
498+
t.Parallel()
499+
500+
selector := &RoundRobinSelector{}
501+
502+
// Mix of virtual and non-virtual auths (e.g., a regular gemini-cli auth without projects
503+
// alongside virtual ones). Should fall back to flat round-robin.
504+
auths := []*Auth{
505+
{ID: "cred-a.json::proj-a1", Attributes: map[string]string{"gemini_virtual_parent": "cred-a.json"}},
506+
{ID: "cred-regular.json"}, // no gemini_virtual_parent
507+
}
508+
509+
// groupByVirtualParent returns nil when any auth lacks the attribute,
510+
// so flat round-robin is used. Sorted by ID: cred-a.json::proj-a1, cred-regular.json
511+
want := []string{
512+
"cred-a.json::proj-a1",
513+
"cred-regular.json",
514+
"cred-a.json::proj-a1",
515+
}
516+
517+
for i, expectedID := range want {
518+
got, err := selector.Pick(context.Background(), "gemini-cli", "", cliproxyexecutor.Options{}, auths)
519+
if err != nil {
520+
t.Fatalf("Pick() #%d error = %v", i, err)
521+
}
522+
if got == nil {
523+
t.Fatalf("Pick() #%d auth = nil", i)
524+
}
525+
if got.ID != expectedID {
526+
t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, expectedID)
527+
}
528+
}
529+
}

0 commit comments

Comments
 (0)