Skip to content

Commit 1c43984

Browse files
committed
feat: add global model aliases with cross-provider fallback
Add a model-aliases configuration section that maps user-friendly alias names to provider-specific model names. This enables automatic failover across providers when quota is exhausted. Features: - New model-aliases config section with aliases and providers mappings - Round-robin and fill-first routing strategies - Automatic fallback on 429, 502, 503, 504 errors - Hot-reload support for alias configuration changes - Unit and integration tests for the alias resolver Closes #632
1 parent ee171bc commit 1c43984

File tree

9 files changed

+589
-0
lines changed

9 files changed

+589
-0
lines changed

cmd/server/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"github.com/joho/godotenv"
1919
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
20+
"github.com/router-for-me/CLIProxyAPI/v6/internal/alias"
2021
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
2122
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
2223
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
@@ -382,6 +383,9 @@ func main() {
382383
cfg = &config.Config{}
383384
}
384385

386+
// Initialize global model alias resolver
387+
alias.InitGlobalResolver(&cfg.ModelAliases)
388+
385389
// In cloud deploy mode, check if we have a valid configuration
386390
var configFileExists bool
387391
if isCloudDeploy {

config.example.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,29 @@ quota-exceeded:
7373
routing:
7474
strategy: "round-robin" # round-robin (default), fill-first
7575

76+
# Global model aliases for cross-provider failover
77+
# Map user-friendly alias names to provider-specific model names.
78+
# When quota is exhausted on one provider, automatically fail over to the next.
79+
# model-aliases:
80+
# default-strategy: round-robin # round-robin (default), fill-first
81+
# aliases:
82+
# - alias: opus-4.5
83+
# strategy: fill-first # optional: override default strategy for this alias
84+
# providers:
85+
# - provider: antigravity
86+
# model: gemini-claude-opus-4-5-thinking
87+
# - provider: kiro
88+
# model: kiro-claude-opus-4-5-agentic
89+
# - provider: claude
90+
# model: claude-opus-4-5-20251101
91+
# - alias: sonnet-4
92+
# # uses default round-robin strategy
93+
# providers:
94+
# - provider: antigravity
95+
# model: gemini-claude-sonnet-4-thinking
96+
# - provider: kiro
97+
# model: kiro-claude-sonnet-4-agentic
98+
7699
# When true, enable authentication for the WebSocket API (/v1/ws).
77100
ws-auth: false
78101

internal/alias/global.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package alias
2+
3+
import (
4+
"sync"
5+
6+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
7+
)
8+
9+
var (
10+
globalResolver *Resolver
11+
globalResolverOnce sync.Once
12+
globalResolverMu sync.RWMutex
13+
)
14+
15+
// GetGlobalResolver returns the global alias resolver instance.
16+
// Creates a new empty resolver if not initialized.
17+
func GetGlobalResolver() *Resolver {
18+
globalResolverOnce.Do(func() {
19+
globalResolver = NewResolver(nil)
20+
})
21+
globalResolverMu.RLock()
22+
defer globalResolverMu.RUnlock()
23+
return globalResolver
24+
}
25+
26+
// InitGlobalResolver initializes the global resolver with configuration.
27+
// Should be called during server startup.
28+
func InitGlobalResolver(cfg *config.ModelAliasConfig) {
29+
globalResolverOnce.Do(func() {
30+
globalResolver = NewResolver(cfg)
31+
})
32+
globalResolverMu.Lock()
33+
defer globalResolverMu.Unlock()
34+
if globalResolver != nil && cfg != nil {
35+
globalResolver.Update(cfg)
36+
}
37+
}
38+
39+
// UpdateGlobalResolver updates the global resolver configuration.
40+
// Used for hot-reload.
41+
func UpdateGlobalResolver(cfg *config.ModelAliasConfig) {
42+
r := GetGlobalResolver()
43+
if r != nil && cfg != nil {
44+
r.Update(cfg)
45+
}
46+
}

internal/alias/integration_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//go:build integration
2+
3+
package alias
4+
5+
import (
6+
"testing"
7+
8+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
9+
)
10+
11+
func TestGlobalResolverIntegration(t *testing.T) {
12+
cfg := &config.ModelAliasConfig{
13+
DefaultStrategy: "round-robin",
14+
Aliases: []config.ModelAlias{
15+
{
16+
Alias: "test-alias",
17+
Providers: []config.AliasProvider{
18+
{Provider: "test-provider", Model: "test-model"},
19+
},
20+
},
21+
},
22+
}
23+
24+
InitGlobalResolver(cfg)
25+
26+
r := GetGlobalResolver()
27+
if r == nil {
28+
t.Fatal("expected global resolver")
29+
}
30+
31+
resolved := r.Resolve("test-alias")
32+
if resolved == nil {
33+
t.Fatal("expected resolved alias")
34+
}
35+
36+
// Test update
37+
newCfg := &config.ModelAliasConfig{
38+
Aliases: []config.ModelAlias{
39+
{
40+
Alias: "new-alias",
41+
Providers: []config.AliasProvider{
42+
{Provider: "new-provider", Model: "new-model"},
43+
},
44+
},
45+
},
46+
}
47+
UpdateGlobalResolver(newCfg)
48+
49+
resolved = r.Resolve("new-alias")
50+
if resolved == nil {
51+
t.Fatal("expected new alias after update")
52+
}
53+
}

internal/alias/resolver.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Package alias provides global model alias resolution for cross-provider routing.
2+
package alias
3+
4+
import (
5+
"strings"
6+
"sync"
7+
8+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
9+
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
10+
log "github.com/sirupsen/logrus"
11+
)
12+
13+
// ResolvedAlias contains the resolution result for a model alias.
14+
type ResolvedAlias struct {
15+
// OriginalAlias is the alias that was resolved.
16+
OriginalAlias string
17+
// Strategy is the routing strategy for this alias.
18+
Strategy string
19+
// Providers is the ordered list of provider mappings.
20+
Providers []config.AliasProvider
21+
}
22+
23+
// SelectedProvider contains the selected provider and model for a request.
24+
type SelectedProvider struct {
25+
// Provider is the selected provider name.
26+
Provider string
27+
// Model is the provider-specific model name.
28+
Model string
29+
// Index is the index in the providers list (for tracking).
30+
Index int
31+
}
32+
33+
// Resolver handles global model alias resolution with routing strategies.
34+
type Resolver struct {
35+
mu sync.RWMutex
36+
aliases map[string]*ResolvedAlias // lowercase alias -> resolved
37+
defaultStrategy string
38+
counters map[string]int // alias -> round-robin counter
39+
}
40+
41+
// NewResolver creates a new alias resolver with the given configuration.
42+
func NewResolver(cfg *config.ModelAliasConfig) *Resolver {
43+
r := &Resolver{
44+
aliases: make(map[string]*ResolvedAlias),
45+
defaultStrategy: "round-robin",
46+
counters: make(map[string]int),
47+
}
48+
if cfg != nil {
49+
r.Update(cfg)
50+
}
51+
return r
52+
}
53+
54+
// Update refreshes the resolver configuration (for hot-reload).
55+
func (r *Resolver) Update(cfg *config.ModelAliasConfig) {
56+
if cfg == nil {
57+
return
58+
}
59+
r.mu.Lock()
60+
defer r.mu.Unlock()
61+
62+
r.defaultStrategy = cfg.DefaultStrategy
63+
if r.defaultStrategy == "" {
64+
r.defaultStrategy = "round-robin"
65+
}
66+
67+
r.aliases = make(map[string]*ResolvedAlias, len(cfg.Aliases))
68+
for _, alias := range cfg.Aliases {
69+
key := strings.ToLower(alias.Alias)
70+
strategy := alias.Strategy
71+
if strategy == "" {
72+
strategy = r.defaultStrategy
73+
}
74+
r.aliases[key] = &ResolvedAlias{
75+
OriginalAlias: alias.Alias,
76+
Strategy: strategy,
77+
Providers: alias.Providers,
78+
}
79+
log.Debugf("model alias registered: %s -> %d providers (strategy: %s)",
80+
alias.Alias, len(alias.Providers), strategy)
81+
}
82+
83+
if len(r.aliases) > 0 {
84+
log.Infof("model aliases: loaded %d alias(es)", len(r.aliases))
85+
}
86+
}
87+
88+
// Resolve checks if the model name is an alias and returns resolution info.
89+
// Returns nil if the model is not an alias.
90+
func (r *Resolver) Resolve(modelName string) *ResolvedAlias {
91+
if modelName == "" {
92+
return nil
93+
}
94+
r.mu.RLock()
95+
defer r.mu.RUnlock()
96+
97+
key := strings.ToLower(strings.TrimSpace(modelName))
98+
return r.aliases[key]
99+
}
100+
101+
// SelectProvider selects the next provider based on the routing strategy.
102+
// It filters out providers that don't have available credentials.
103+
func (r *Resolver) SelectProvider(resolved *ResolvedAlias) *SelectedProvider {
104+
if resolved == nil || len(resolved.Providers) == 0 {
105+
return nil
106+
}
107+
108+
// Filter to providers that have registered models
109+
available := make([]int, 0, len(resolved.Providers))
110+
for i, p := range resolved.Providers {
111+
if providers := util.GetProviderName(p.Model); len(providers) > 0 {
112+
available = append(available, i)
113+
}
114+
}
115+
116+
if len(available) == 0 {
117+
log.Debugf("model alias %s: no providers have available credentials", resolved.OriginalAlias)
118+
return nil
119+
}
120+
121+
var selectedIdx int
122+
switch resolved.Strategy {
123+
case "fill-first", "fillfirst", "ff":
124+
// Always pick first available
125+
selectedIdx = available[0]
126+
default: // round-robin
127+
r.mu.Lock()
128+
counter := r.counters[resolved.OriginalAlias]
129+
r.counters[resolved.OriginalAlias] = counter + 1
130+
if counter >= 2_147_483_640 {
131+
r.counters[resolved.OriginalAlias] = 0
132+
}
133+
r.mu.Unlock()
134+
selectedIdx = available[counter%len(available)]
135+
}
136+
137+
p := resolved.Providers[selectedIdx]
138+
log.Debugf("model alias %s: selected provider %s with model %s (strategy: %s)",
139+
resolved.OriginalAlias, p.Provider, p.Model, resolved.Strategy)
140+
141+
return &SelectedProvider{
142+
Provider: p.Provider,
143+
Model: p.Model,
144+
Index: selectedIdx,
145+
}
146+
}
147+
148+
// GetAliases returns a copy of current aliases (for debugging/status).
149+
func (r *Resolver) GetAliases() map[string]*ResolvedAlias {
150+
r.mu.RLock()
151+
defer r.mu.RUnlock()
152+
153+
result := make(map[string]*ResolvedAlias, len(r.aliases))
154+
for k, v := range r.aliases {
155+
result[k] = v
156+
}
157+
return result
158+
}

0 commit comments

Comments
 (0)