Skip to content

Commit e73d9d9

Browse files
authored
feat(config): support multiple API keys for failover (#1707)
* feat(config): support multiple API keys for failover Add api_keys field to ModelConfig to support multiple API keys with automatic failover. When multiple keys are configured, they are expanded into separate model entries with fallbacks set up for key-level failover. Example config: { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", "api_keys": ["key1", "key2", "key3"] } Expands internally to: - glm-4.7 (key1) -> fallbacks: [glm-4.7__key_1, glm-4.7__key_2] - glm-4.7__key_1 (key2) - glm-4.7__key_2 (key3) Backward compatible: single api_key still works as before. * fix(providers): change cooldown tracking from provider to ModelKey This enables proper key-switching when multiple API keys share the same provider. Previously, when one key failed, all keys were blocked because cooldown was tracked per-provider. Now each (provider, model) combination has independent cooldown, allowing fallback to alternate keys when one is rate limited. Includes TestMultiKeyWithModelFallback and related failover tests.
1 parent 08f305d commit e73d9d9

5 files changed

Lines changed: 794 additions & 17 deletions

File tree

pkg/config/config.go

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -603,9 +603,11 @@ type ModelConfig struct {
603603
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
604604

605605
// HTTP-based providers
606-
APIBase string `json:"api_base,omitempty"` // API endpoint URL
607-
APIKey string `json:"api_key"` // API authentication key
608-
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
606+
APIBase string `json:"api_base,omitempty"` // API endpoint URL
607+
APIKey string `json:"api_key"` // API authentication key (single key)
608+
APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
609+
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
610+
Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover
609611

610612
// Special providers (CLI-based, OAuth, etc.)
611613
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
@@ -874,6 +876,9 @@ func LoadConfig(path string) (*Config, error) {
874876
return nil, err
875877
}
876878

879+
// Expand multi-key configs into separate entries for key-level failover
880+
cfg.ModelList = ExpandMultiKeyModels(cfg.ModelList)
881+
877882
// Migrate legacy channel config fields to new unified structures
878883
cfg.migrateChannelConfigs()
879884

@@ -920,14 +925,25 @@ func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelCo
920925

921926
// resolveAPIKeys decrypts or dereferences each api_key in models in-place.
922927
// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).
928+
// Also resolves api_keys array if present.
923929
func resolveAPIKeys(models []ModelConfig, configDir string) error {
924930
cr := credential.NewResolver(configDir)
925931
for i := range models {
932+
// Resolve single APIKey
926933
resolved, err := cr.Resolve(models[i].APIKey)
927934
if err != nil {
928935
return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err)
929936
}
930937
models[i].APIKey = resolved
938+
939+
// Resolve APIKeys array
940+
for j, key := range models[i].APIKeys {
941+
resolved, err := cr.Resolve(key)
942+
if err != nil {
943+
return fmt.Errorf("model_list[%d] (%s): api_keys[%d]: %w", i, models[i].ModelName, j, err)
944+
}
945+
models[i].APIKeys[j] = resolved
946+
}
931947
}
932948
return nil
933949
}
@@ -1098,6 +1114,89 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string {
10981114
return all
10991115
}
11001116

1117+
// ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into
1118+
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
1119+
// and the original entry's fallbacks are set up to chain through the expanded entries.
1120+
//
1121+
// Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]}
1122+
// Becomes:
1123+
// - {"model_name": "gpt-4", "api_key": "k1", "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]}
1124+
// - {"model_name": "gpt-4__key_1", "api_key": "k2"}
1125+
// - {"model_name": "gpt-4__key_2", "api_key": "k3"}
1126+
func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {
1127+
var expanded []ModelConfig
1128+
1129+
for _, m := range models {
1130+
keys := MergeAPIKeys(m.APIKey, m.APIKeys)
1131+
1132+
// Single key or no keys: keep as-is
1133+
if len(keys) <= 1 {
1134+
// Ensure APIKey is set from APIKeys if needed
1135+
if m.APIKey == "" && len(keys) == 1 {
1136+
m.APIKey = keys[0]
1137+
}
1138+
m.APIKeys = nil // Clear APIKeys to avoid confusion
1139+
expanded = append(expanded, m)
1140+
continue
1141+
}
1142+
1143+
// Multiple keys: expand
1144+
originalName := m.ModelName
1145+
1146+
// Create entries for additional keys (key_1, key_2, ...)
1147+
var fallbackNames []string
1148+
for i := 1; i < len(keys); i++ {
1149+
suffix := fmt.Sprintf("__key_%d", i)
1150+
expandedName := originalName + suffix
1151+
1152+
// Create a copy for the additional key
1153+
additionalEntry := ModelConfig{
1154+
ModelName: expandedName,
1155+
Model: m.Model,
1156+
APIBase: m.APIBase,
1157+
APIKey: keys[i],
1158+
Proxy: m.Proxy,
1159+
AuthMethod: m.AuthMethod,
1160+
ConnectMode: m.ConnectMode,
1161+
Workspace: m.Workspace,
1162+
RPM: m.RPM,
1163+
MaxTokensField: m.MaxTokensField,
1164+
RequestTimeout: m.RequestTimeout,
1165+
ThinkingLevel: m.ThinkingLevel,
1166+
}
1167+
expanded = append(expanded, additionalEntry)
1168+
fallbackNames = append(fallbackNames, expandedName)
1169+
}
1170+
1171+
// Create the primary entry with first key and fallbacks
1172+
primaryEntry := ModelConfig{
1173+
ModelName: originalName,
1174+
Model: m.Model,
1175+
APIBase: m.APIBase,
1176+
APIKey: keys[0],
1177+
Proxy: m.Proxy,
1178+
AuthMethod: m.AuthMethod,
1179+
ConnectMode: m.ConnectMode,
1180+
Workspace: m.Workspace,
1181+
RPM: m.RPM,
1182+
MaxTokensField: m.MaxTokensField,
1183+
RequestTimeout: m.RequestTimeout,
1184+
ThinkingLevel: m.ThinkingLevel,
1185+
}
1186+
1187+
// Prepend new fallbacks to existing ones
1188+
if len(fallbackNames) > 0 {
1189+
primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...)
1190+
} else if len(m.Fallbacks) > 0 {
1191+
primaryEntry.Fallbacks = m.Fallbacks
1192+
}
1193+
1194+
expanded = append(expanded, primaryEntry)
1195+
}
1196+
1197+
return expanded
1198+
}
1199+
11011200
func (t *ToolsConfig) IsToolEnabled(name string) bool {
11021201
switch name {
11031202
case "web":

0 commit comments

Comments
 (0)