Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 102 additions & 3 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,11 @@ type ModelConfig struct {
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")

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

// Special providers (CLI-based, OAuth, etc.)
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
Expand Down Expand Up @@ -861,6 +863,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}

// Expand multi-key configs into separate entries for key-level failover
cfg.ModelList = ExpandMultiKeyModels(cfg.ModelList)

// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()

Expand Down Expand Up @@ -907,14 +912,25 @@ func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelCo

// resolveAPIKeys decrypts or dereferences each api_key in models in-place.
// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).
// Also resolves api_keys array if present.
func resolveAPIKeys(models []ModelConfig, configDir string) error {
cr := credential.NewResolver(configDir)
for i := range models {
// Resolve single APIKey
resolved, err := cr.Resolve(models[i].APIKey)
if err != nil {
return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err)
}
models[i].APIKey = resolved

// Resolve APIKeys array
for j, key := range models[i].APIKeys {
resolved, err := cr.Resolve(key)
if err != nil {
return fmt.Errorf("model_list[%d] (%s): api_keys[%d]: %w", i, models[i].ModelName, j, err)
}
models[i].APIKeys[j] = resolved
}
}
return nil
}
Expand Down Expand Up @@ -1085,6 +1101,89 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string {
return all
}

// ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
// and the original entry's fallbacks are set up to chain through the expanded entries.
//
// Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]}
// Becomes:
// - {"model_name": "gpt-4", "api_key": "k1", "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]}
// - {"model_name": "gpt-4__key_1", "api_key": "k2"}
// - {"model_name": "gpt-4__key_2", "api_key": "k3"}
func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {
var expanded []ModelConfig

for _, m := range models {
keys := MergeAPIKeys(m.APIKey, m.APIKeys)

// Single key or no keys: keep as-is
if len(keys) <= 1 {
// Ensure APIKey is set from APIKeys if needed
if m.APIKey == "" && len(keys) == 1 {
m.APIKey = keys[0]
}
m.APIKeys = nil // Clear APIKeys to avoid confusion
expanded = append(expanded, m)
continue
}

// Multiple keys: expand
originalName := m.ModelName

// Create entries for additional keys (key_1, key_2, ...)
var fallbackNames []string
for i := 1; i < len(keys); i++ {
suffix := fmt.Sprintf("__key_%d", i)
expandedName := originalName + suffix

// Create a copy for the additional key
additionalEntry := ModelConfig{
ModelName: expandedName,
Model: m.Model,
APIBase: m.APIBase,
APIKey: keys[i],
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
}
expanded = append(expanded, additionalEntry)
fallbackNames = append(fallbackNames, expandedName)
}

// Create the primary entry with first key and fallbacks
primaryEntry := ModelConfig{
ModelName: originalName,
Model: m.Model,
APIBase: m.APIBase,
APIKey: keys[0],
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
}

// Prepend new fallbacks to existing ones
if len(fallbackNames) > 0 {
primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...)
} else if len(m.Fallbacks) > 0 {
primaryEntry.Fallbacks = m.Fallbacks
}

expanded = append(expanded, primaryEntry)
}

return expanded
}

func (t *ToolsConfig) IsToolEnabled(name string) bool {
switch name {
case "web":
Expand Down
Loading
Loading