From 5ada8c71ba1274ff440c5a659a02a37fbac8034d Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:30:41 +0800 Subject: [PATCH] fix(config): inherit provider settings for model_list entries --- pkg/config/config.go | 131 +++++++++++++++++++++++++++-- pkg/config/config_test.go | 170 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 8 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 33a5db8ae..8d4d7a3eb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -920,14 +920,7 @@ func LoadConfig(path string) (*Config, error) { cfg.ModelList = ConvertProvidersToModelList(cfg) } - // Inherit credentials from providers to model_list entries (#1635). - // When both providers and model_list are present, model_list entries - // whose api_key/api_base are empty will inherit from the matching - // provider (matched by protocol prefix). Explicit model_list values - // always take precedence. - if cfg.HasProvidersConfig() { - InheritProviderCredentials(cfg.ModelList, cfg.Providers) - } + cfg.applyModelListProviderInheritance() // Validate model_list for uniqueness and required fields if err := cfg.ValidateModelList(); err != nil { @@ -990,6 +983,128 @@ func resolveAPIKeys(models []ModelConfig, configDir string) error { return nil } +// applyModelListProviderInheritance fills missing model_list fields from the +// matching legacy providers entry while keeping any explicit model_list values. +func (c *Config) applyModelListProviderInheritance() { + if len(c.ModelList) == 0 { + return + } + + for i := range c.ModelList { + protocol := canonicalModelProtocol(c.ModelList[i].Model) + if protocol == "" { + continue + } + + providerCfg, ok := providerConfigForProtocol(c.Providers, protocol) + if !ok || !hasInheritableProviderFields(providerCfg) { + continue + } + + inheritModelConfig(&c.ModelList[i], providerCfg) + } +} + +func canonicalModelProtocol(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + + protocol, _, found := strings.Cut(model, "/") + if !found { + return "openai" + } + + switch strings.ToLower(protocol) { + case "copilot": + return "github-copilot" + default: + return strings.ToLower(protocol) + } +} + +func providerConfigForProtocol(providers ProvidersConfig, protocol string) (ProviderConfig, bool) { + switch protocol { + case "anthropic": + return providers.Anthropic, true + case "openai": + return providers.OpenAI.ProviderConfig, true + case "litellm": + return providers.LiteLLM, true + case "openrouter": + return providers.OpenRouter, true + case "groq": + return providers.Groq, true + case "zhipu": + return providers.Zhipu, true + case "vllm": + return providers.VLLM, true + case "gemini": + return providers.Gemini, true + case "nvidia": + return providers.Nvidia, true + case "ollama": + return providers.Ollama, true + case "moonshot": + return providers.Moonshot, true + case "shengsuanyun": + return providers.ShengSuanYun, true + case "deepseek": + return providers.DeepSeek, true + case "cerebras": + return providers.Cerebras, true + case "vivgrid": + return providers.Vivgrid, true + case "volcengine": + return providers.VolcEngine, true + case "github-copilot": + return providers.GitHubCopilot, true + case "antigravity": + return providers.Antigravity, true + case "qwen": + return providers.Qwen, true + case "mistral": + return providers.Mistral, true + case "avian": + return providers.Avian, true + case "minimax": + return providers.Minimax, true + default: + return ProviderConfig{}, false + } +} + +func hasInheritableProviderFields(provider ProviderConfig) bool { + return provider.APIKey != "" || + provider.APIBase != "" || + provider.Proxy != "" || + provider.RequestTimeout != 0 || + provider.AuthMethod != "" || + provider.ConnectMode != "" +} + +func inheritModelConfig(model *ModelConfig, provider ProviderConfig) { + if model.APIKey == "" { + model.APIKey = provider.APIKey + } + if model.APIBase == "" { + model.APIBase = provider.APIBase + } + if model.Proxy == "" { + model.Proxy = provider.Proxy + } + if model.RequestTimeout == 0 { + model.RequestTimeout = provider.RequestTimeout + } + if model.AuthMethod == "" { + model.AuthMethod = provider.AuthMethod + } + if model.ConnectMode == "" { + model.ConnectMode = provider.ConnectMode + } +} + func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 588c04645..229f4541f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -555,6 +555,176 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) { } } +func TestLoadConfig_ModelListInheritsProviderFields(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configJSON := `{ + "providers": { + "litellm": { + "api_key": "shared-key", + "api_base": "http://host:4000/v1", + "proxy": "http://proxy:8080", + "request_timeout": 45 + } + }, + "model_list": [ + { + "model_name": "kimi1", + "model": "litellm/kimi-1" + } + ] +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("os.WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + modelCfg, err := cfg.GetModelConfig("kimi1") + if err != nil { + t.Fatalf("GetModelConfig() error: %v", err) + } + if modelCfg.APIKey != "shared-key" { + t.Fatalf("APIKey = %q, want %q", modelCfg.APIKey, "shared-key") + } + if modelCfg.APIBase != "http://host:4000/v1" { + t.Fatalf("APIBase = %q, want %q", modelCfg.APIBase, "http://host:4000/v1") + } + if modelCfg.Proxy != "http://proxy:8080" { + t.Fatalf("Proxy = %q, want %q", modelCfg.Proxy, "http://proxy:8080") + } + if modelCfg.RequestTimeout != 45 { + t.Fatalf("RequestTimeout = %d, want %d", modelCfg.RequestTimeout, 45) + } +} + +func TestLoadConfig_ModelListKeepsExplicitOverrides(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configJSON := `{ + "providers": { + "litellm": { + "api_key": "shared-key", + "api_base": "http://host:4000/v1", + "proxy": "http://proxy:8080", + "request_timeout": 45 + } + }, + "model_list": [ + { + "model_name": "kimi1", + "model": "litellm/kimi-1", + "api_key": "model-key", + "request_timeout": 90 + } + ] +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("os.WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + modelCfg, err := cfg.GetModelConfig("kimi1") + if err != nil { + t.Fatalf("GetModelConfig() error: %v", err) + } + if modelCfg.APIKey != "model-key" { + t.Fatalf("APIKey = %q, want %q", modelCfg.APIKey, "model-key") + } + if modelCfg.APIBase != "http://host:4000/v1" { + t.Fatalf("APIBase = %q, want %q", modelCfg.APIBase, "http://host:4000/v1") + } + if modelCfg.Proxy != "http://proxy:8080" { + t.Fatalf("Proxy = %q, want %q", modelCfg.Proxy, "http://proxy:8080") + } + if modelCfg.RequestTimeout != 90 { + t.Fatalf("RequestTimeout = %d, want %d", modelCfg.RequestTimeout, 90) + } +} + +func TestLoadConfig_ModelListInheritsOpenAIProviderForBareModel(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configJSON := `{ + "providers": { + "openai": { + "api_key": "openai-key", + "api_base": "https://api.example.com/v1" + } + }, + "model_list": [ + { + "model_name": "gpt4", + "model": "gpt-4.1" + } + ] +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("os.WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + modelCfg, err := cfg.GetModelConfig("gpt4") + if err != nil { + t.Fatalf("GetModelConfig() error: %v", err) + } + if modelCfg.APIKey != "openai-key" { + t.Fatalf("APIKey = %q, want %q", modelCfg.APIKey, "openai-key") + } + if modelCfg.APIBase != "https://api.example.com/v1" { + t.Fatalf("APIBase = %q, want %q", modelCfg.APIBase, "https://api.example.com/v1") + } +} + +func TestLoadConfig_ModelListInheritsCopilotProviderAlias(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configJSON := `{ + "providers": { + "github_copilot": { + "api_base": "localhost:5000", + "connect_mode": "stdio" + } + }, + "model_list": [ + { + "model_name": "copilot", + "model": "copilot/gpt-5" + } + ] +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("os.WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + modelCfg, err := cfg.GetModelConfig("copilot") + if err != nil { + t.Fatalf("GetModelConfig() error: %v", err) + } + if modelCfg.APIBase != "localhost:5000" { + t.Fatalf("APIBase = %q, want %q", modelCfg.APIBase, "localhost:5000") + } + if modelCfg.ConnectMode != "stdio" { + t.Fatalf("ConnectMode = %q, want %q", modelCfg.ConnectMode, "stdio") + } +} + // TestDefaultConfig_DMScope verifies the default dm_scope value // TestDefaultConfig_SummarizationThresholds verifies summarization defaults func TestDefaultConfig_SummarizationThresholds(t *testing.T) {