Skip to content

Commit 0d2f07f

Browse files
committed
feat(routing): add weighted strategy with smooth weighted round-robin selector
Add a third routing strategy alongside round-robin (default) and fill-first that distributes traffic across credentials according to a configurable weight per key. The new WeightedSelector uses the smooth weighted round-robin algorithm popularized by nginx (Wensong Zhang) so weights like 7:3 yield an interleaved 7:3 share without long runs on any single credential. Each key type (ClaudeKey, CodexKey, GeminiKey, OpenAICompatibility, VertexCompatKey) gains an optional 'weight' field that the synthesizer propagates to auth.Attributes['weight']. Missing, non-positive, or unparsable weights coerce to 1 so a misconfigured credential still participates fairly. Priority remains a strict tier above weight - high-priority credentials are exhausted before weights inside lower tiers are considered. Wire the strategy through builder.go, service.go hot reload, and the management API normalize function. The selector is intentionally not registered as a built-in selector so the conductor's legacy pick path drives it directly, keeping the scheduler fast-path unchanged. Documented in config.example.yaml. Covered by 9 unit tests in sdk/cliproxy/auth/weighted_selector_test.go including the canonical SWRR sequence, ratio convergence over 1000 picks, default-weight behavior, priority dominance, per-route state isolation, and a concurrency stress test.
1 parent 563766a commit 0d2f07f

9 files changed

Lines changed: 414 additions & 1 deletion

File tree

config.example.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,15 @@ quota-exceeded:
112112

113113
# Routing strategy for selecting credentials when multiple match.
114114
routing:
115-
strategy: "round-robin" # round-robin (default), fill-first
115+
strategy: "round-robin" # round-robin (default), fill-first, weighted
116+
# round-robin: even cyclic distribution across all available credentials.
117+
# fill-first: always use the first available credential; "burns" one before
118+
# moving to the next. Useful to stagger rolling-window quotas.
119+
# weighted: smooth weighted round-robin (nginx SWRR). Each credential's
120+
# `weight` field sets its relative traffic share. Missing or
121+
# non-positive weights default to 1. Priority still dominates:
122+
# higher-priority credentials are exhausted first; weights only
123+
# rank credentials inside the same priority tier.
116124
# Enable universal session-sticky routing for all clients.
117125
# Session IDs are extracted from: metadata.user_id (Claude Code session format),
118126
# X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI),
@@ -149,6 +157,8 @@ nonstream-keepalive-interval: 0
149157
# Gemini API keys
150158
# gemini-api-key:
151159
# - api-key: "AIzaSy...01"
160+
# priority: 10 # higher tiers are picked before lower tiers
161+
# weight: 7 # only used when routing.strategy is "weighted"
152162
# prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
153163
# base-url: "https://generativelanguage.googleapis.com"
154164
# headers:

internal/api/handlers/management/config_basic.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,8 @@ func normalizeRoutingStrategy(strategy string) (string, bool) {
286286
return "round-robin", true
287287
case "fill-first", "fillfirst", "ff":
288288
return "fill-first", true
289+
case "weighted", "weight", "w":
290+
return "weighted", true
289291
default:
290292
return "", false
291293
}

internal/config/config.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,11 @@ type ClaudeKey struct {
393393
// Higher values are preferred; defaults to 0.
394394
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
395395

396+
// Weight controls relative traffic share when routing.strategy is "weighted".
397+
// Higher values receive proportionally more traffic. Defaults to 0 (treated as 1 by the selector).
398+
// Ignored by other strategies.
399+
Weight int `yaml:"weight,omitempty" json:"weight,omitempty"`
400+
396401
// Prefix optionally namespaces models for this credential (e.g., "teamA/claude-sonnet-4").
397402
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
398403

@@ -446,6 +451,11 @@ type CodexKey struct {
446451
// Higher values are preferred; defaults to 0.
447452
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
448453

454+
// Weight controls relative traffic share when routing.strategy is "weighted".
455+
// Higher values receive proportionally more traffic. Defaults to 0 (treated as 1 by the selector).
456+
// Ignored by other strategies.
457+
Weight int `yaml:"weight,omitempty" json:"weight,omitempty"`
458+
449459
// Prefix optionally namespaces models for this credential (e.g., "teamA/gpt-5-codex").
450460
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
451461

@@ -494,6 +504,11 @@ type GeminiKey struct {
494504
// Higher values are preferred; defaults to 0.
495505
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
496506

507+
// Weight controls relative traffic share when routing.strategy is "weighted".
508+
// Higher values receive proportionally more traffic. Defaults to 0 (treated as 1 by the selector).
509+
// Ignored by other strategies.
510+
Weight int `yaml:"weight,omitempty" json:"weight,omitempty"`
511+
497512
// Prefix optionally namespaces models for this credential (e.g., "teamA/gemini-3-pro-preview").
498513
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
499514

@@ -584,6 +599,11 @@ type OpenAICompatibility struct {
584599
// Higher values are preferred; defaults to 0.
585600
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
586601

602+
// Weight controls relative traffic share when routing.strategy is "weighted".
603+
// Higher values receive proportionally more traffic. Defaults to 0 (treated as 1 by the selector).
604+
// Ignored by other strategies.
605+
Weight int `yaml:"weight,omitempty" json:"weight,omitempty"`
606+
587607
// Disabled prevents this provider from being used for routing.
588608
Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
589609

internal/config/vertex_compat.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ type VertexCompatKey struct {
1717
// Higher values are preferred; defaults to 0.
1818
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
1919

20+
// Weight controls relative traffic share when routing.strategy is "weighted".
21+
// Higher values receive proportionally more traffic. Defaults to 0 (treated as 1 by the selector).
22+
// Ignored by other strategies.
23+
Weight int `yaml:"weight,omitempty" json:"weight,omitempty"`
24+
2025
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
2126
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
2227

internal/watcher/synthesizer/config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea
6767
if entry.Priority != 0 {
6868
attrs["priority"] = strconv.Itoa(entry.Priority)
6969
}
70+
if entry.Weight != 0 {
71+
attrs["weight"] = strconv.Itoa(entry.Weight)
72+
}
7073
if base != "" {
7174
attrs["base_url"] = base
7275
}
@@ -114,6 +117,9 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
114117
if ck.Priority != 0 {
115118
attrs["priority"] = strconv.Itoa(ck.Priority)
116119
}
120+
if ck.Weight != 0 {
121+
attrs["weight"] = strconv.Itoa(ck.Weight)
122+
}
117123
if base != "" {
118124
attrs["base_url"] = base
119125
}
@@ -161,6 +167,9 @@ func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreau
161167
if ck.Priority != 0 {
162168
attrs["priority"] = strconv.Itoa(ck.Priority)
163169
}
170+
if ck.Weight != 0 {
171+
attrs["weight"] = strconv.Itoa(ck.Weight)
172+
}
164173
if ck.BaseURL != "" {
165174
attrs["base_url"] = ck.BaseURL
166175
}
@@ -225,6 +234,9 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
225234
if compat.Priority != 0 {
226235
attrs["priority"] = strconv.Itoa(compat.Priority)
227236
}
237+
if compat.Weight != 0 {
238+
attrs["weight"] = strconv.Itoa(compat.Weight)
239+
}
228240
if key != "" {
229241
attrs["api_key"] = key
230242
}
@@ -259,6 +271,9 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
259271
if compat.Priority != 0 {
260272
attrs["priority"] = strconv.Itoa(compat.Priority)
261273
}
274+
if compat.Weight != 0 {
275+
attrs["weight"] = strconv.Itoa(compat.Weight)
276+
}
262277
if hash := diff.ComputeOpenAICompatModelsHash(compat.Models); hash != "" {
263278
attrs["models_hash"] = hash
264279
}
@@ -304,6 +319,9 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor
304319
if compat.Priority != 0 {
305320
attrs["priority"] = strconv.Itoa(compat.Priority)
306321
}
322+
if compat.Weight != 0 {
323+
attrs["weight"] = strconv.Itoa(compat.Weight)
324+
}
307325
if key != "" {
308326
attrs["api_key"] = key
309327
}

sdk/cliproxy/auth/selector.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ type RoundRobinSelector struct {
3535
// rolling-window subscription caps (e.g. chat message limits).
3636
type FillFirstSelector struct{}
3737

38+
// WeightedSelector distributes traffic across credentials using the smooth
39+
// weighted round-robin algorithm popularized by nginx (Wensong Zhang).
40+
// Each auth's relative share is taken from its "weight" attribute, defaulting
41+
// to 1 when missing or non-positive. Within a priority tier, the algorithm
42+
// produces a smooth, drift-free distribution that avoids long runs on a single
43+
// credential while still honoring the configured ratios over time.
44+
// Reference: https://github.com/phusion/nginx/commit/27e9498
45+
type WeightedSelector struct {
46+
mu sync.Mutex
47+
states map[string]map[string]int // provider:model -> authID -> currentWeight
48+
maxKeys int
49+
}
50+
3851
type blockReason int
3952

4053
const (
@@ -128,6 +141,24 @@ func authPriority(auth *Auth) int {
128141
return parsed
129142
}
130143

144+
// authWeight reads the integer weight from an auth's attributes. Missing,
145+
// blank, invalid, or non-positive values resolve to 1 so a credential without
146+
// an explicit weight still participates fairly alongside weighted peers.
147+
func authWeight(auth *Auth) int {
148+
if auth == nil || auth.Attributes == nil {
149+
return 1
150+
}
151+
raw := strings.TrimSpace(auth.Attributes["weight"])
152+
if raw == "" {
153+
return 1
154+
}
155+
parsed, err := strconv.Atoi(raw)
156+
if err != nil || parsed < 1 {
157+
return 1
158+
}
159+
return parsed
160+
}
161+
131162
func canonicalModelKey(model string) string {
132163
model = strings.TrimSpace(model)
133164
if model == "" {
@@ -368,6 +399,84 @@ func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, op
368399
return available[0], nil
369400
}
370401

402+
// Pick selects the next available auth using smooth weighted round-robin.
403+
// Each call advances every candidate's current weight by its configured weight,
404+
// chooses the candidate with the highest current weight, then subtracts the
405+
// total weight from the winner. This produces a jitter-free interleaving that
406+
// matches the configured ratio over any sufficiently long window.
407+
func (s *WeightedSelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
408+
_ = opts
409+
now := time.Now()
410+
available, err := getAvailableAuths(auths, provider, model, now)
411+
if err != nil {
412+
return nil, err
413+
}
414+
available = preferCodexWebsocketAuths(ctx, provider, available)
415+
if len(available) == 1 {
416+
return available[0], nil
417+
}
418+
419+
key := provider + ":" + canonicalModelKey(model)
420+
s.mu.Lock()
421+
defer s.mu.Unlock()
422+
if s.states == nil {
423+
s.states = make(map[string]map[string]int)
424+
}
425+
limit := s.maxKeys
426+
if limit <= 0 {
427+
limit = 4096
428+
}
429+
state, ok := s.states[key]
430+
if !ok {
431+
if len(s.states) >= limit {
432+
s.states = make(map[string]map[string]int)
433+
}
434+
state = make(map[string]int)
435+
s.states[key] = state
436+
}
437+
438+
// Smooth weighted round-robin: advance every candidate's current weight by
439+
// its configured weight, pick the maximum, and subtract the total from it.
440+
var best *Auth
441+
var bestCurrent int
442+
totalWeight := 0
443+
for _, auth := range available {
444+
if auth == nil {
445+
continue
446+
}
447+
w := authWeight(auth)
448+
totalWeight += w
449+
cw := state[auth.ID] + w
450+
state[auth.ID] = cw
451+
if best == nil || cw > bestCurrent || (cw == bestCurrent && auth.ID < best.ID) {
452+
best = auth
453+
bestCurrent = cw
454+
}
455+
}
456+
if best == nil {
457+
return nil, &Error{Code: "auth_not_found", Message: "selector returned no auth"}
458+
}
459+
state[best.ID] = bestCurrent - totalWeight
460+
461+
// Bound state size by pruning entries for auths that are no longer in the
462+
// available set. Done opportunistically when the map grows much larger than
463+
// the candidate set to keep the common-case fast.
464+
if len(state) > 4*len(available)+8 {
465+
keep := make(map[string]struct{}, len(available))
466+
for _, auth := range available {
467+
if auth != nil {
468+
keep[auth.ID] = struct{}{}
469+
}
470+
}
471+
for id := range state {
472+
if _, ok := keep[id]; !ok {
473+
delete(state, id)
474+
}
475+
}
476+
}
477+
return best, nil
478+
}
479+
371480
func isAuthBlockedForModel(auth *Auth, model string, now time.Time) (bool, blockReason, time.Time) {
372481
if auth == nil {
373482
return true, blockReasonOther, time.Time{}

0 commit comments

Comments
 (0)