diff --git a/cmd/server/main.go b/cmd/server/main.go index 2b20bcb5f..fdbe60853 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "github.com/joho/godotenv" configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access" + "github.com/router-for-me/CLIProxyAPI/v6/internal/alias" "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo" "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -382,6 +383,9 @@ func main() { cfg = &config.Config{} } + // Initialize global model alias resolver + alias.InitGlobalResolver(&cfg.ModelAliases) + // In cloud deploy mode, check if we have a valid configuration var configFileExists bool if isCloudDeploy { diff --git a/config.example.yaml b/config.example.yaml index 85e006503..62046dee1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -73,6 +73,29 @@ quota-exceeded: routing: strategy: "round-robin" # round-robin (default), fill-first +# Global model aliases for cross-provider failover +# Map user-friendly alias names to provider-specific model names. +# When quota is exhausted on one provider, automatically fail over to the next. +# model-aliases: +# default-strategy: round-robin # round-robin (default), fill-first +# aliases: +# - alias: opus-4.5 +# strategy: fill-first # optional: override default strategy for this alias +# providers: +# - provider: antigravity +# model: gemini-claude-opus-4-5-thinking +# - provider: kiro +# model: kiro-claude-opus-4-5-agentic +# - provider: claude +# model: claude-opus-4-5-20251101 +# - alias: sonnet-4 +# # uses default round-robin strategy +# providers: +# - provider: antigravity +# model: gemini-claude-sonnet-4-thinking +# - provider: kiro +# model: kiro-claude-sonnet-4-agentic + # When true, enable authentication for the WebSocket API (/v1/ws). ws-auth: false diff --git a/internal/alias/global.go b/internal/alias/global.go new file mode 100644 index 000000000..fcb47ed60 --- /dev/null +++ b/internal/alias/global.go @@ -0,0 +1,33 @@ +package alias + +import ( + "sync" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +var ( + globalResolver *Resolver + globalResolverOnce sync.Once +) + +// GetGlobalResolver returns the global alias resolver instance. +// Creates a new empty resolver if not initialized. +func GetGlobalResolver() *Resolver { + globalResolverOnce.Do(func() { + globalResolver = NewResolver(nil) + }) + return globalResolver +} + +// InitGlobalResolver initializes the global resolver with configuration. +// Should be called during server startup. +func InitGlobalResolver(cfg *config.ModelAliasConfig) { + GetGlobalResolver().Update(cfg) +} + +// UpdateGlobalResolver updates the global resolver configuration. +// Used for hot-reload. +func UpdateGlobalResolver(cfg *config.ModelAliasConfig) { + GetGlobalResolver().Update(cfg) +} diff --git a/internal/alias/integration_test.go b/internal/alias/integration_test.go new file mode 100644 index 000000000..807d9f83e --- /dev/null +++ b/internal/alias/integration_test.go @@ -0,0 +1,53 @@ +//go:build integration + +package alias + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestGlobalResolverIntegration(t *testing.T) { + cfg := &config.ModelAliasConfig{ + DefaultStrategy: "round-robin", + Aliases: []config.ModelAlias{ + { + Alias: "test-alias", + Providers: []config.AliasProvider{ + {Provider: "test-provider", Model: "test-model"}, + }, + }, + }, + } + + InitGlobalResolver(cfg) + + r := GetGlobalResolver() + if r == nil { + t.Fatal("expected global resolver") + } + + resolved := r.Resolve("test-alias") + if resolved == nil { + t.Fatal("expected resolved alias") + } + + // Test update + newCfg := &config.ModelAliasConfig{ + Aliases: []config.ModelAlias{ + { + Alias: "new-alias", + Providers: []config.AliasProvider{ + {Provider: "new-provider", Model: "new-model"}, + }, + }, + }, + } + UpdateGlobalResolver(newCfg) + + resolved = r.Resolve("new-alias") + if resolved == nil { + t.Fatal("expected new alias after update") + } +} diff --git a/internal/alias/resolver.go b/internal/alias/resolver.go new file mode 100644 index 000000000..af2f6196e --- /dev/null +++ b/internal/alias/resolver.go @@ -0,0 +1,158 @@ +// Package alias provides global model alias resolution for cross-provider routing. +package alias + +import ( + "strings" + "sync" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + log "github.com/sirupsen/logrus" +) + +// ResolvedAlias contains the resolution result for a model alias. +type ResolvedAlias struct { + // OriginalAlias is the alias that was resolved. + OriginalAlias string + // Strategy is the routing strategy for this alias. + Strategy string + // Providers is the ordered list of provider mappings. + Providers []config.AliasProvider +} + +// SelectedProvider contains the selected provider and model for a request. +type SelectedProvider struct { + // Provider is the selected provider name. + Provider string + // Model is the provider-specific model name. + Model string + // Index is the index in the providers list (for tracking). + Index int +} + +// Resolver handles global model alias resolution with routing strategies. +type Resolver struct { + mu sync.RWMutex + aliases map[string]*ResolvedAlias // lowercase alias -> resolved + defaultStrategy string + counters map[string]int // alias -> round-robin counter +} + +// NewResolver creates a new alias resolver with the given configuration. +func NewResolver(cfg *config.ModelAliasConfig) *Resolver { + r := &Resolver{ + aliases: make(map[string]*ResolvedAlias), + defaultStrategy: "round-robin", + counters: make(map[string]int), + } + if cfg != nil { + r.Update(cfg) + } + return r +} + +// Update refreshes the resolver configuration (for hot-reload). +func (r *Resolver) Update(cfg *config.ModelAliasConfig) { + if cfg == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + + r.defaultStrategy = cfg.DefaultStrategy + if r.defaultStrategy == "" { + r.defaultStrategy = "round-robin" + } + + r.aliases = make(map[string]*ResolvedAlias, len(cfg.Aliases)) + for _, alias := range cfg.Aliases { + key := strings.ToLower(alias.Alias) + strategy := alias.Strategy + if strategy == "" { + strategy = r.defaultStrategy + } + r.aliases[key] = &ResolvedAlias{ + OriginalAlias: alias.Alias, + Strategy: strategy, + Providers: alias.Providers, + } + log.Debugf("model alias registered: %s -> %d providers (strategy: %s)", + alias.Alias, len(alias.Providers), strategy) + } + + if len(r.aliases) > 0 { + log.Infof("model aliases: loaded %d alias(es)", len(r.aliases)) + } +} + +// Resolve checks if the model name is an alias and returns resolution info. +// Returns nil if the model is not an alias. +func (r *Resolver) Resolve(modelName string) *ResolvedAlias { + if modelName == "" { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + + key := strings.ToLower(strings.TrimSpace(modelName)) + return r.aliases[key] +} + +// SelectProvider selects the next provider based on the routing strategy. +// It filters out providers that don't have available credentials. +func (r *Resolver) SelectProvider(resolved *ResolvedAlias) *SelectedProvider { + if resolved == nil || len(resolved.Providers) == 0 { + return nil + } + + // Filter to providers that have registered models + available := make([]int, 0, len(resolved.Providers)) + for i, p := range resolved.Providers { + if providers := util.GetProviderName(p.Model); len(providers) > 0 { + available = append(available, i) + } + } + + if len(available) == 0 { + log.Debugf("model alias %s: no providers have available credentials", resolved.OriginalAlias) + return nil + } + + var selectedIdx int + switch resolved.Strategy { + case "fill-first", "fillfirst", "ff": + // Always pick first available + selectedIdx = available[0] + default: // round-robin + r.mu.Lock() + counter := r.counters[resolved.OriginalAlias] + r.counters[resolved.OriginalAlias] = counter + 1 + if counter >= 2_147_483_640 { + r.counters[resolved.OriginalAlias] = 0 + } + r.mu.Unlock() + selectedIdx = available[counter%len(available)] + } + + p := resolved.Providers[selectedIdx] + log.Debugf("model alias %s: selected provider %s with model %s (strategy: %s)", + resolved.OriginalAlias, p.Provider, p.Model, resolved.Strategy) + + return &SelectedProvider{ + Provider: p.Provider, + Model: p.Model, + Index: selectedIdx, + } +} + +// GetAliases returns a copy of current aliases (for debugging/status). +func (r *Resolver) GetAliases() map[string]*ResolvedAlias { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make(map[string]*ResolvedAlias, len(r.aliases)) + for k, v := range r.aliases { + result[k] = v + } + return result +} diff --git a/internal/alias/resolver_test.go b/internal/alias/resolver_test.go new file mode 100644 index 000000000..54a20358b --- /dev/null +++ b/internal/alias/resolver_test.go @@ -0,0 +1,271 @@ +package alias + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestNewResolver(t *testing.T) { + cfg := &config.ModelAliasConfig{ + DefaultStrategy: "round-robin", + Aliases: []config.ModelAlias{ + { + Alias: "opus-4.5", + Providers: []config.AliasProvider{ + {Provider: "antigravity", Model: "gemini-claude-opus-4-5"}, + {Provider: "kiro", Model: "kiro-claude-opus-4-5"}, + }, + }, + }, + } + + r := NewResolver(cfg) + if r == nil { + t.Fatal("expected non-nil resolver") + } + + aliases := r.GetAliases() + if len(aliases) != 1 { + t.Errorf("expected 1 alias, got %d", len(aliases)) + } +} + +func TestResolve(t *testing.T) { + cfg := &config.ModelAliasConfig{ + DefaultStrategy: "round-robin", + Aliases: []config.ModelAlias{ + { + Alias: "opus-4.5", + Strategy: "fill-first", + Providers: []config.AliasProvider{ + {Provider: "antigravity", Model: "gemini-claude-opus-4-5"}, + }, + }, + }, + } + + r := NewResolver(cfg) + + // Test exact match + resolved := r.Resolve("opus-4.5") + if resolved == nil { + t.Fatal("expected resolved alias") + } + if resolved.Strategy != "fill-first" { + t.Errorf("expected strategy fill-first, got %s", resolved.Strategy) + } + + // Test case-insensitive + resolved = r.Resolve("OPUS-4.5") + if resolved == nil { + t.Fatal("expected case-insensitive match") + } + + // Test non-alias + resolved = r.Resolve("claude-sonnet-4") + if resolved != nil { + t.Error("expected nil for non-alias") + } +} + +func TestDefaultStrategy(t *testing.T) { + cfg := &config.ModelAliasConfig{ + DefaultStrategy: "fill-first", + Aliases: []config.ModelAlias{ + { + Alias: "test-model", + // No strategy specified - should use default + Providers: []config.AliasProvider{ + {Provider: "test", Model: "test-model-v1"}, + }, + }, + }, + } + + r := NewResolver(cfg) + resolved := r.Resolve("test-model") + if resolved == nil { + t.Fatal("expected resolved alias") + } + if resolved.Strategy != "fill-first" { + t.Errorf("expected default strategy fill-first, got %s", resolved.Strategy) + } +} + +func TestUpdate(t *testing.T) { + r := NewResolver(nil) + + // Initially empty + if len(r.GetAliases()) != 0 { + t.Error("expected empty aliases initially") + } + + // Update with config + cfg := &config.ModelAliasConfig{ + Aliases: []config.ModelAlias{ + { + Alias: "new-alias", + Providers: []config.AliasProvider{ + {Provider: "test", Model: "test-model"}, + }, + }, + }, + } + r.Update(cfg) + + if len(r.GetAliases()) != 1 { + t.Error("expected 1 alias after update") + } +} + +func TestSelectProviderNilInput(t *testing.T) { + r := NewResolver(nil) + + // Nil resolved alias + selected := r.SelectProvider(nil) + if selected != nil { + t.Error("expected nil for nil input") + } + + // Empty providers + selected = r.SelectProvider(&ResolvedAlias{ + OriginalAlias: "test", + Strategy: "round-robin", + Providers: nil, + }) + if selected != nil { + t.Error("expected nil for empty providers") + } + + selected = r.SelectProvider(&ResolvedAlias{ + OriginalAlias: "test", + Strategy: "round-robin", + Providers: []config.AliasProvider{}, + }) + if selected != nil { + t.Error("expected nil for zero-length providers") + } +} + +func TestSelectProviderStrategies(t *testing.T) { + // Note: SelectProvider calls util.GetProviderName which requires + // registered models. These tests verify the strategy logic and + // edge cases for the selection algorithm. + + r := NewResolver(&config.ModelAliasConfig{ + DefaultStrategy: "round-robin", + Aliases: []config.ModelAlias{ + { + Alias: "test-rr", + Strategy: "round-robin", + Providers: []config.AliasProvider{ + {Provider: "p1", Model: "model1"}, + {Provider: "p2", Model: "model2"}, + }, + }, + { + Alias: "test-ff", + Strategy: "fill-first", + Providers: []config.AliasProvider{ + {Provider: "p1", Model: "model1"}, + {Provider: "p2", Model: "model2"}, + }, + }, + }, + }) + + // Verify aliases are registered correctly + rrAlias := r.Resolve("test-rr") + if rrAlias == nil { + t.Fatal("expected round-robin alias to be registered") + } + if rrAlias.Strategy != "round-robin" { + t.Errorf("expected strategy round-robin, got %s", rrAlias.Strategy) + } + + ffAlias := r.Resolve("test-ff") + if ffAlias == nil { + t.Fatal("expected fill-first alias to be registered") + } + if ffAlias.Strategy != "fill-first" { + t.Errorf("expected strategy fill-first, got %s", ffAlias.Strategy) + } + + // Verify provider count + if len(rrAlias.Providers) != 2 { + t.Errorf("expected 2 providers for round-robin, got %d", len(rrAlias.Providers)) + } + if len(ffAlias.Providers) != 2 { + t.Errorf("expected 2 providers for fill-first, got %d", len(ffAlias.Providers)) + } +} + +func TestSelectProviderRoundRobinCounter(t *testing.T) { + // Note: SelectProvider only increments counter when there are available + // providers (those returning non-empty from util.GetProviderName). + // Since we don't have registered models in unit tests, this test verifies + // the counter initialization and structure. + + r := NewResolver(nil) + + // Verify counters map exists and is empty initially + r.mu.RLock() + if r.counters == nil { + t.Error("expected counters map to be initialized") + } + initialLen := len(r.counters) + r.mu.RUnlock() + + if initialLen != 0 { + t.Errorf("expected empty counters, got %d", initialLen) + } + + // Calling SelectProvider with no available providers should not crash + // and should return nil (no models registered) + resolved := &ResolvedAlias{ + OriginalAlias: "counter-test", + Strategy: "round-robin", + Providers: []config.AliasProvider{ + {Provider: "p1", Model: "m1"}, + }, + } + + selected := r.SelectProvider(resolved) + // Should return nil because util.GetProviderName returns empty for unregistered models + if selected != nil { + // If it's not nil, that means there are registered models in the test environment + // which would be unexpected but acceptable + t.Logf("SelectProvider returned non-nil, model may be registered: %+v", selected) + } +} + +func TestSelectProviderFillFirstVariants(t *testing.T) { + // Test that fill-first strategy aliases work correctly + testCases := []string{"fill-first", "fillfirst", "ff"} + + for _, strategy := range testCases { + cfg := &config.ModelAliasConfig{ + Aliases: []config.ModelAlias{ + { + Alias: "test-" + strategy, + Strategy: strategy, + Providers: []config.AliasProvider{ + {Provider: "p1", Model: "m1"}, + }, + }, + }, + } + + r := NewResolver(cfg) + resolved := r.Resolve("test-" + strategy) + if resolved == nil { + t.Fatalf("expected alias for strategy %s", strategy) + } + + // Verify the resolved strategy is preserved as specified + if resolved.Strategy != strategy { + t.Errorf("expected strategy %s, got %s", strategy, resolved.Strategy) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index dea56dff9..beaaa9729 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,6 +66,9 @@ type Config struct { // Routing controls credential selection behavior. Routing RoutingConfig `yaml:"routing" json:"routing"` + // ModelAliases defines global model alias mappings for cross-provider routing. + ModelAliases ModelAliasConfig `yaml:"model-aliases" json:"model-aliases"` + // WebsocketAuth enables or disables authentication for the WebSocket API. WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"` @@ -137,6 +140,33 @@ type RoutingConfig struct { Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"` } +// ModelAliasConfig defines global model alias mappings with routing strategies. +type ModelAliasConfig struct { + // DefaultStrategy is the default routing strategy for aliases ("round-robin" or "fill-first"). + // Defaults to "round-robin" if not specified. + DefaultStrategy string `yaml:"default-strategy,omitempty" json:"default-strategy,omitempty"` + // Aliases defines the list of model alias mappings. + Aliases []ModelAlias `yaml:"aliases,omitempty" json:"aliases,omitempty"` +} + +// ModelAlias maps a single alias to multiple provider-specific models. +type ModelAlias struct { + // Alias is the user-facing model name (e.g., "opus-4.5"). + Alias string `yaml:"alias" json:"alias"` + // Strategy overrides the default routing strategy for this alias. + Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"` + // Providers lists the provider-specific model mappings in priority order. + Providers []AliasProvider `yaml:"providers" json:"providers"` +} + +// AliasProvider maps a provider name to its specific model name. +type AliasProvider struct { + // Provider is the provider identifier (e.g., "antigravity", "kiro", "claude"). + Provider string `yaml:"provider" json:"provider"` + // Model is the provider-specific model name. + Model string `yaml:"model" json:"model"` +} + // AmpModelMapping defines a model name mapping for Amp CLI requests. // When Amp requests a model that isn't available locally, this mapping // allows routing to an alternative model that IS available. diff --git a/internal/watcher/config_reload.go b/internal/watcher/config_reload.go index 244f738e6..4d164f669 100644 --- a/internal/watcher/config_reload.go +++ b/internal/watcher/config_reload.go @@ -8,6 +8,7 @@ import ( "os" "time" + "github.com/router-for-me/CLIProxyAPI/v6/internal/alias" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" @@ -113,6 +114,9 @@ func (w *Watcher) reloadConfig() bool { log.Debugf("log level updated - debug mode changed from %t to %t", oldConfig.Debug, newConfig.Debug) } + // Update global model alias resolver + alias.UpdateGlobalResolver(&newConfig.ModelAliases) + if oldConfig != nil { details := diff.BuildConfigChangeDetails(oldConfig, newConfig) if len(details) > 0 { diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 86ed92767..f5a1120d1 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/alias" "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" @@ -20,6 +21,7 @@ import ( coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + log "github.com/sirupsen/logrus" "golang.org/x/net/context" ) @@ -50,6 +52,99 @@ const ( defaultStreamingBootstrapRetries = 0 ) +// aliasTarget holds a single provider+model pair from an alias resolution. +type aliasTarget struct { + provider string + model string +} + +// aliasInfo holds all alias targets and the initially selected index. +type aliasInfo struct { + targets []aliasTarget + selectedIdx int + selectedModel string // the model name that was selected for the initial request +} + +// isAliasFallbackEligible returns true if the error warrants trying the next alias target. +func isAliasFallbackEligible(err error) bool { + if err == nil { + return false + } + status := 0 + if se, ok := err.(interface{ StatusCode() int }); ok && se != nil { + status = se.StatusCode() + } + switch status { + case http.StatusTooManyRequests, // 429 - rate limited + http.StatusServiceUnavailable, // 503 - service unavailable + http.StatusGatewayTimeout, // 504 - gateway timeout + http.StatusBadGateway: // 502 - bad gateway + return true + default: + return false + } +} + +// executeFn is the function signature for executing a request. +type executeFn func(ctx context.Context, providers []string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) + +// tryAliasFallback attempts fallback to other alias targets when the initial request fails. +func (h *BaseAPIHandler) tryAliasFallback(ctx context.Context, info *aliasInfo, rawJSON []byte, metadata map[string]any, opts coreexecutor.Options, execute executeFn) (coreexecutor.Response, error) { + var resp coreexecutor.Response + var err error + + for i, target := range info.targets { + if i == info.selectedIdx { + continue // skip the one we already tried + } + log.Debugf("alias fallback: trying target %d/%d: provider=%s model=%s", + i+1, len(info.targets), target.provider, target.model) + + // Get providers for this target's model + targetProviders := util.GetProviderName(target.model) + if len(targetProviders) == 0 { + continue + } + + // Build request with target's model + targetReq := coreexecutor.Request{ + Model: target.model, + Payload: cloneBytes(rawJSON), + Metadata: cloneMetadata(metadata), + } + + resp, err = execute(ctx, targetProviders, targetReq, opts) + if err == nil { + log.Debugf("alias fallback: succeeded with provider=%s model=%s", target.provider, target.model) + return resp, nil + } + + // If this error is not fallback-eligible, stop trying + if !isAliasFallbackEligible(err) { + break + } + } + + return resp, err +} + +// buildErrorMessage creates an ErrorMessage from an error with proper status code and headers. +func buildErrorMessage(err error) *interfaces.ErrorMessage { + status := http.StatusInternalServerError + if se, ok := err.(interface{ StatusCode() int }); ok && se != nil { + if code := se.StatusCode(); code > 0 { + status = code + } + } + var addon http.Header + if he, ok := err.(interface{ Headers() http.Header }); ok && he != nil { + if hdr := he.Headers(); hdr != nil { + addon = hdr.Clone() + } + } + return &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon} +} + // BuildErrorResponseBody builds an OpenAI-compatible JSON error response body. // If errText is already valid JSON, it is returned as-is to preserve upstream error payloads. func BuildErrorResponseBody(status int, errText string) []byte { @@ -318,7 +413,16 @@ func appendAPIResponse(c *gin.Context, data []byte) { // ExecuteWithAuthManager executes a non-streaming request via the core auth manager. // This path is the only supported execution route. func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) { - providers, normalizedModel, metadata, errMsg := h.getRequestDetails(modelName) + // Check if this is an alias with multiple fallback targets + aliasInfo := h.getAliasTargets(modelName) + + // If alias, use the selected model for initial request; otherwise use original + effectiveModel := modelName + if aliasInfo != nil { + effectiveModel = aliasInfo.selectedModel + } + + providers, normalizedModel, metadata, errMsg := h.getRequestDetails(effectiveModel) if errMsg != nil { return nil, errMsg } @@ -338,20 +442,17 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType } opts.Metadata = mergeMetadata(cloneMetadata(metadata), reqMeta) resp, err := h.AuthManager.Execute(ctx, providers, req, opts) + + // If we have alias fallback targets and the error is fallback-eligible, try them + if err != nil && aliasInfo != nil && isAliasFallbackEligible(err) { + resp, err = h.tryAliasFallback(ctx, aliasInfo, rawJSON, metadata, opts, + func(ctx context.Context, providers []string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + return h.AuthManager.Execute(ctx, providers, req, opts) + }) + } + if err != nil { - status := http.StatusInternalServerError - if se, ok := err.(interface{ StatusCode() int }); ok && se != nil { - if code := se.StatusCode(); code > 0 { - status = code - } - } - var addon http.Header - if he, ok := err.(interface{ Headers() http.Header }); ok && he != nil { - if hdr := he.Headers(); hdr != nil { - addon = hdr.Clone() - } - } - return nil, &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon} + return nil, buildErrorMessage(err) } return cloneBytes(resp.Payload), nil } @@ -359,7 +460,16 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType // ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager. // This path is the only supported execution route. func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) { - providers, normalizedModel, metadata, errMsg := h.getRequestDetails(modelName) + // Check if this is an alias with multiple fallback targets + aliasInfo := h.getAliasTargets(modelName) + + // If alias, use the selected model for initial request; otherwise use original + effectiveModel := modelName + if aliasInfo != nil { + effectiveModel = aliasInfo.selectedModel + } + + providers, normalizedModel, metadata, errMsg := h.getRequestDetails(effectiveModel) if errMsg != nil { return nil, errMsg } @@ -379,20 +489,17 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle } opts.Metadata = mergeMetadata(cloneMetadata(metadata), reqMeta) resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts) + + // If we have alias fallback targets and the error is fallback-eligible, try them + if err != nil && aliasInfo != nil && isAliasFallbackEligible(err) { + resp, err = h.tryAliasFallback(ctx, aliasInfo, rawJSON, metadata, opts, + func(ctx context.Context, providers []string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + return h.AuthManager.ExecuteCount(ctx, providers, req, opts) + }) + } + if err != nil { - status := http.StatusInternalServerError - if se, ok := err.(interface{ StatusCode() int }); ok && se != nil { - if code := se.StatusCode(); code > 0 { - status = code - } - } - var addon http.Header - if he, ok := err.(interface{ Headers() http.Header }); ok && he != nil { - if hdr := he.Headers(); hdr != nil { - addon = hdr.Clone() - } - } - return nil, &interfaces.ErrorMessage{StatusCode: status, Error: err, Addon: addon} + return nil, buildErrorMessage(err) } return cloneBytes(resp.Payload), nil } @@ -577,6 +684,50 @@ func cloneBytes(src []byte) []byte { return dst } +// getAliasTargets returns all alias targets for a model if it's an alias. +// Returns nil if the model is not an alias or has no valid fallback targets. +func (h *BaseAPIHandler) getAliasTargets(modelName string) *aliasInfo { + resolvedModelName := util.ResolveAutoModel(modelName) + resolved := alias.GetGlobalResolver().Resolve(resolvedModelName) + if resolved == nil || len(resolved.Providers) == 0 { + return nil + } + + selected := alias.GetGlobalResolver().SelectProvider(resolved) + if selected == nil { + return nil + } + + // Build filtered targets list and track selected index in the filtered list + targets := make([]aliasTarget, 0, len(resolved.Providers)) + selectedIdxInTargets := -1 + for _, p := range resolved.Providers { + if providerNames := util.GetProviderName(p.Model); len(providerNames) > 0 { + if p.Provider == selected.Provider && p.Model == selected.Model { + selectedIdxInTargets = len(targets) + } + targets = append(targets, aliasTarget{ + provider: p.Provider, + model: p.Model, + }) + } + } + + if len(targets) <= 1 { + return nil + } + + if selectedIdxInTargets == -1 { + return nil + } + + return &aliasInfo{ + targets: targets, + selectedIdx: selectedIdxInTargets, + selectedModel: selected.Model, + } +} + func normalizeModelMetadata(modelName string) (string, map[string]any) { return util.NormalizeThinkingModel(modelName) }