Skip to content

Commit 92038f5

Browse files
committed
feat(auth): add routing source preference
Let routing prefer API-backed or file-backed credentials before cross-source priority so auth files can stay as fallback when API config is primary. Keep sticky bindings aligned with the preferred source layer as availability changes.
1 parent 2995f7d commit 92038f5

10 files changed

Lines changed: 530 additions & 34 deletions

File tree

config.example.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ quota-exceeded:
107107
# Routing strategy for selecting credentials when multiple match.
108108
routing:
109109
strategy: "round-robin" # round-robin (default), fill-first, sticky-round-robin
110+
source-preference: "none" # none (default), api-first, file-first
110111
sticky-ttl: 1800 # Sticky binding TTL in seconds for sticky-round-robin
111112

112113
# When true, enable authentication for the WebSocket API (/v1/ws).

internal/config/config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
2525
DefaultPprofAddr = "127.0.0.1:8316"
2626
DefaultRoutingStickyTTL = 1800
27+
DefaultRoutingSourcePreference = "none"
2728
DefaultQuotaCacheRefreshInterval = 3600
2829
)
2930

@@ -222,6 +223,9 @@ type RoutingConfig struct {
222223
// Strategy selects the credential selection strategy.
223224
// Supported values: "round-robin" (default), "fill-first", "sticky-round-robin".
224225
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`
226+
// SourcePreference controls whether routing prefers API-backed or file-backed sources first.
227+
// Supported values: "none" (default), "api-first", "file-first".
228+
SourcePreference string `yaml:"source-preference,omitempty" json:"source-preference,omitempty"`
225229
// StickyTTL controls how long sticky-round-robin bindings remain valid, in seconds.
226230
StickyTTL int `yaml:"sticky-ttl,omitempty" json:"sticky-ttl,omitempty"`
227231
}
@@ -569,6 +573,15 @@ func LoadConfig(configFile string) (*Config, error) {
569573
// LoadConfigOptional reads YAML from configFile.
570574
// If optional is true and the file is missing, it returns an empty Config.
571575
// If optional is true and the file is empty or invalid, it returns an empty Config.
576+
func normalizeRoutingSourcePreference(value string) string {
577+
switch strings.TrimSpace(value) {
578+
case "api-first", "file-first":
579+
return strings.TrimSpace(value)
580+
default:
581+
return DefaultRoutingSourcePreference
582+
}
583+
}
584+
572585
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
573586
// Read the entire configuration file into memory.
574587
data, err := os.ReadFile(configFile)
@@ -601,6 +614,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
601614
cfg.Pprof.Addr = DefaultPprofAddr
602615
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
603616
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
617+
cfg.Routing.SourcePreference = DefaultRoutingSourcePreference
604618
cfg.Routing.StickyTTL = DefaultRoutingStickyTTL
605619
if err = yaml.Unmarshal(data, &cfg); err != nil {
606620
if optional {
@@ -650,6 +664,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
650664
cfg.Pprof.Addr = DefaultPprofAddr
651665
}
652666

667+
cfg.Routing.SourcePreference = normalizeRoutingSourcePreference(cfg.Routing.SourcePreference)
668+
653669
if cfg.Routing.StickyTTL <= 0 {
654670
cfg.Routing.StickyTTL = DefaultRoutingStickyTTL
655671
}
@@ -1341,6 +1357,8 @@ func isKnownDefaultValue(path []string, node *yaml.Node) bool {
13411357
return node.Value == DefaultPanelGitHubRepository
13421358
case "routing.strategy":
13431359
return node.Value == "round-robin"
1360+
case "routing.source-preference":
1361+
return node.Value == DefaultRoutingSourcePreference
13441362
}
13451363
}
13461364

internal/config/config_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,72 @@ port: 8317
6464
}
6565
}
6666

67+
func TestLoadConfigOptionalRoutingSourcePreference(t *testing.T) {
68+
t.Parallel()
69+
70+
tests := []struct {
71+
name string
72+
content string
73+
want string
74+
}{
75+
{
76+
name: "defaults to none when field is absent",
77+
content: `routing:
78+
strategy: "round-robin"
79+
`,
80+
want: DefaultRoutingSourcePreference,
81+
},
82+
{
83+
name: "keeps api-first",
84+
content: `routing:
85+
source-preference: "api-first"
86+
`,
87+
want: "api-first",
88+
},
89+
{
90+
name: "keeps file-first",
91+
content: `routing:
92+
source-preference: "file-first"
93+
`,
94+
want: "file-first",
95+
},
96+
{
97+
name: "invalid value falls back to none",
98+
content: `routing:
99+
source-preference: "invalid"
100+
`,
101+
want: DefaultRoutingSourcePreference,
102+
},
103+
{
104+
name: "empty value falls back to none",
105+
content: `routing:
106+
source-preference: ""
107+
`,
108+
want: DefaultRoutingSourcePreference,
109+
},
110+
}
111+
112+
for _, tt := range tests {
113+
tt := tt
114+
t.Run(tt.name, func(t *testing.T) {
115+
t.Parallel()
116+
117+
configPath := filepath.Join(t.TempDir(), "config.yaml")
118+
if err := os.WriteFile(configPath, []byte(tt.content), 0o644); err != nil {
119+
t.Fatalf("write config: %v", err)
120+
}
121+
122+
cfg, err := LoadConfigOptional(configPath, false)
123+
if err != nil {
124+
t.Fatalf("LoadConfigOptional() error = %v", err)
125+
}
126+
if cfg.Routing.SourcePreference != tt.want {
127+
t.Fatalf("Routing.SourcePreference = %q, want %q", cfg.Routing.SourcePreference, tt.want)
128+
}
129+
})
130+
}
131+
}
132+
67133
func TestIsKnownDefaultValueRecognizesQuotaCacheRefreshInterval(t *testing.T) {
68134
t.Parallel()
69135

@@ -77,3 +143,17 @@ func TestIsKnownDefaultValueRecognizesQuotaCacheRefreshInterval(t *testing.T) {
77143
t.Fatal("expected quota-cache-refresh-interval=120 not to be treated as known default")
78144
}
79145
}
146+
147+
func TestIsKnownDefaultValueRecognizesRoutingSourcePreference(t *testing.T) {
148+
t.Parallel()
149+
150+
defaultNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: DefaultRoutingSourcePreference}
151+
if !isKnownDefaultValue([]string{"routing", "source-preference"}, defaultNode) {
152+
t.Fatal("expected routing.source-preference=none to be treated as known default")
153+
}
154+
155+
nonDefaultNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "api-first"}
156+
if isKnownDefaultValue([]string{"routing", "source-preference"}, nonDefaultNode) {
157+
t.Fatal("expected routing.source-preference=api-first not to be treated as known default")
158+
}
159+
}

internal/watcher/synthesizer/config.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea
5757
proxyURL := strings.TrimSpace(entry.ProxyURL)
5858
id, token := idGen.Next("gemini:apikey", key, base)
5959
attrs := map[string]string{
60-
"source": fmt.Sprintf("config:gemini[%s]", token),
61-
"api_key": key,
60+
"source": fmt.Sprintf("config:gemini[%s]", token),
61+
"source_type": "api",
62+
"api_key": key,
6263
}
6364
if entry.Priority != 0 {
6465
attrs["priority"] = strconv.Itoa(entry.Priority)
@@ -104,8 +105,9 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
104105
base := strings.TrimSpace(ck.BaseURL)
105106
id, token := idGen.Next("claude:apikey", key, base)
106107
attrs := map[string]string{
107-
"source": fmt.Sprintf("config:claude[%s]", token),
108-
"api_key": key,
108+
"source": fmt.Sprintf("config:claude[%s]", token),
109+
"source_type": "api",
110+
"api_key": key,
109111
}
110112
if ck.Priority != 0 {
111113
attrs["priority"] = strconv.Itoa(ck.Priority)
@@ -151,8 +153,9 @@ func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreau
151153
prefix := strings.TrimSpace(ck.Prefix)
152154
id, token := idGen.Next("codex:apikey", key, ck.BaseURL)
153155
attrs := map[string]string{
154-
"source": fmt.Sprintf("config:codex[%s]", token),
155-
"api_key": key,
156+
"source": fmt.Sprintf("config:codex[%s]", token),
157+
"source_type": "api",
158+
"api_key": key,
156159
}
157160
if ck.Priority != 0 {
158161
attrs["priority"] = strconv.Itoa(ck.Priority)
@@ -211,6 +214,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
211214
id, token := idGen.Next(idKind, key, base, proxyURL)
212215
attrs := map[string]string{
213216
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
217+
"source_type": "api",
214218
"base_url": base,
215219
"compat_name": compat.Name,
216220
"provider_key": providerName,
@@ -245,6 +249,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
245249
id, token := idGen.Next(idKind, base)
246250
attrs := map[string]string{
247251
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
252+
"source_type": "api",
248253
"base_url": base,
249254
"compat_name": compat.Name,
250255
"provider_key": providerName,
@@ -291,6 +296,7 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor
291296
id, token := idGen.Next(idKind, key, base, proxyURL)
292297
attrs := map[string]string{
293298
"source": fmt.Sprintf("config:vertex-apikey[%s]", token),
299+
"source_type": "api",
294300
"base_url": base,
295301
"provider_key": providerName,
296302
}

internal/watcher/synthesizer/config_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,3 +615,38 @@ func TestConfigSynthesizer_AllProviders(t *testing.T) {
615615
}
616616
}
617617
}
618+
619+
func TestConfigSynthesizer_SetsSourceTypeAPIForSynthesizedAuths(t *testing.T) {
620+
synth := NewConfigSynthesizer()
621+
ctx := &SynthesisContext{
622+
Config: &config.Config{
623+
GeminiKey: []config.GeminiKey{{APIKey: "gemini-key"}},
624+
ClaudeKey: []config.ClaudeKey{{APIKey: "claude-key"}},
625+
CodexKey: []config.CodexKey{{APIKey: "codex-key"}},
626+
OpenAICompatibility: []config.OpenAICompatibility{{
627+
Name: "compat",
628+
BaseURL: "https://compat.example.com",
629+
}},
630+
VertexCompatAPIKey: []config.VertexCompatKey{{
631+
APIKey: "vertex-key",
632+
BaseURL: "https://vertex.example.com",
633+
}},
634+
},
635+
Now: time.Now(),
636+
IDGenerator: NewStableIDGenerator(),
637+
}
638+
639+
auths, err := synth.Synthesize(ctx)
640+
if err != nil {
641+
t.Fatalf("unexpected error: %v", err)
642+
}
643+
if len(auths) != 5 {
644+
t.Fatalf("expected 5 auths, got %d", len(auths))
645+
}
646+
647+
for _, auth := range auths {
648+
if got := auth.Attributes["source_type"]; got != "api" {
649+
t.Fatalf("expected auth %s (%s) to have source_type api, got %q", auth.ID, auth.Provider, got)
650+
}
651+
}
652+
}

internal/watcher/synthesizer/file.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,9 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) []
129129
Status: status,
130130
Disabled: disabled,
131131
Attributes: map[string]string{
132-
"source": fullPath,
133-
"path": fullPath,
132+
"source": fullPath,
133+
"source_type": "file",
134+
"path": fullPath,
134135
},
135136
ProxyURL: proxyURL,
136137
Metadata: metadata,
@@ -205,6 +206,7 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an
205206
primary.Attributes["virtual_children"] = strings.Join(projects, ",")
206207
source := primary.Attributes["source"]
207208
authPath := primary.Attributes["path"]
209+
sourceType := strings.TrimSpace(primary.Attributes["source_type"])
208210
originalProvider := primary.Provider
209211
if originalProvider == "" {
210212
originalProvider = "gemini-cli"
@@ -226,6 +228,9 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an
226228
if authPath != "" {
227229
attrs["path"] = authPath
228230
}
231+
if sourceType != "" {
232+
attrs["source_type"] = sourceType
233+
}
229234
// Propagate priority from primary auth to virtual auths
230235
if priorityVal, hasPriority := primary.Attributes["priority"]; hasPriority && priorityVal != "" {
231236
attrs["priority"] = priorityVal

internal/watcher/synthesizer/file_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,3 +955,70 @@ func TestFileSynthesizer_Synthesize_MultiProjectGeminiWithNote(t *testing.T) {
955955
}
956956
}
957957
}
958+
959+
func TestFileSynthesizer_Synthesize_SetsSourceTypeFile(t *testing.T) {
960+
tempDir := t.TempDir()
961+
authData := map[string]any{
962+
"type": "claude",
963+
"email": "file@example.com",
964+
}
965+
data, _ := json.Marshal(authData)
966+
errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644)
967+
if errWriteFile != nil {
968+
t.Fatalf("failed to write auth file: %v", errWriteFile)
969+
}
970+
971+
synth := NewFileSynthesizer()
972+
ctx := &SynthesisContext{
973+
Config: &config.Config{},
974+
AuthDir: tempDir,
975+
Now: time.Now(),
976+
IDGenerator: NewStableIDGenerator(),
977+
}
978+
979+
auths, errSynthesize := synth.Synthesize(ctx)
980+
if errSynthesize != nil {
981+
t.Fatalf("unexpected error: %v", errSynthesize)
982+
}
983+
if len(auths) != 1 {
984+
t.Fatalf("expected 1 auth, got %d", len(auths))
985+
}
986+
if got := auths[0].Attributes["source_type"]; got != "file" {
987+
t.Fatalf("expected source_type file, got %q", got)
988+
}
989+
}
990+
991+
func TestSynthesizeGeminiVirtualAuths_InheritsFileSourceType(t *testing.T) {
992+
now := time.Now()
993+
primary := &coreauth.Auth{
994+
ID: "primary-id",
995+
Provider: "gemini-cli",
996+
Label: "test@example.com",
997+
Attributes: map[string]string{
998+
"source": "test-source",
999+
"path": "/path/to/auth",
1000+
"source_type": "file",
1001+
"priority": "7",
1002+
},
1003+
}
1004+
metadata := map[string]any{
1005+
"project_id": "proj-a, proj-b",
1006+
"email": "test@example.com",
1007+
"type": "gemini",
1008+
}
1009+
1010+
virtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now)
1011+
1012+
if len(virtuals) != 2 {
1013+
t.Fatalf("expected 2 virtuals, got %d", len(virtuals))
1014+
}
1015+
1016+
for i, v := range virtuals {
1017+
if got := v.Attributes["source_type"]; got != "file" {
1018+
t.Fatalf("virtual %d: expected source_type file, got %q", i, got)
1019+
}
1020+
if got := v.Attributes["priority"]; got != "7" {
1021+
t.Fatalf("virtual %d: expected priority 7, got %q", i, got)
1022+
}
1023+
}
1024+
}

sdk/cliproxy/auth/conductor.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ func (m *Manager) SetConfig(cfg *internalconfig.Config) {
363363
m.runtimeConfig.Store(cfg)
364364
if m.scheduler != nil {
365365
m.scheduler.setStickyTTL(cfg.Routing.StickyTTL)
366+
m.scheduler.setSourcePreference(cfg.Routing.SourcePreference)
366367
}
367368
m.rebuildAPIKeyModelAliasFromRuntimeConfig()
368369
}

0 commit comments

Comments
 (0)