diff --git a/internal/config/builtin_providers.go b/internal/config/builtin_providers.go deleted file mode 100644 index 5dfca5b5..00000000 --- a/internal/config/builtin_providers.go +++ /dev/null @@ -1,87 +0,0 @@ -package config - -import "neo-code/internal/provider" - -const ( - OpenAIName = "openai" - OpenAIDefaultBaseURL = "https://api.openai.com/v1" - OpenAIDefaultModel = "gpt-4.1" - OpenAIDefaultAPIKeyEnv = "OPENAI_API_KEY" - - GeminiName = "gemini" - GeminiDefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" - GeminiDefaultModel = "gemini-2.5-flash" - GeminiDefaultAPIKeyEnv = "GEMINI_API_KEY" - - OpenLLName = "openll" - OpenLLDefaultBaseURL = "https://www.openll.top/v1" - OpenLLDefaultModel = "gpt-5.4" - OpenLLDefaultAPIKeyEnv = "AI_API_KEY" - - QiniuName = "qiniu" - QiniuDefaultBaseURL = "https://api.qnaigc.com/v1" - QiniuDefaultModel = "openai/gpt-5" - QiniuDefaultAPIKeyEnv = "QINIU_API_KEY" -) - -// OpenAIProvider returns the builtin OpenAI provider definition. -func OpenAIProvider() ProviderConfig { - return ProviderConfig{ - Name: OpenAIName, - Driver: provider.DriverOpenAICompat, - BaseURL: OpenAIDefaultBaseURL, - Model: OpenAIDefaultModel, - APIKeyEnv: OpenAIDefaultAPIKeyEnv, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - Source: ProviderSourceBuiltin, - } -} - -// GeminiProvider returns the builtin Gemini provider definition. -func GeminiProvider() ProviderConfig { - return ProviderConfig{ - Name: GeminiName, - Driver: provider.DriverOpenAICompat, - BaseURL: GeminiDefaultBaseURL, - Model: GeminiDefaultModel, - APIKeyEnv: GeminiDefaultAPIKeyEnv, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - Source: ProviderSourceBuiltin, - } -} - -// OpenLLProvider returns the builtin OpenLL provider definition. -func OpenLLProvider() ProviderConfig { - return ProviderConfig{ - Name: OpenLLName, - Driver: provider.DriverOpenAICompat, - BaseURL: OpenLLDefaultBaseURL, - Model: OpenLLDefaultModel, - APIKeyEnv: OpenLLDefaultAPIKeyEnv, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - Source: ProviderSourceBuiltin, - } -} - -// QiniuProvider returns the builtin Qiniu provider definition. -func QiniuProvider() ProviderConfig { - return ProviderConfig{ - Name: QiniuName, - Driver: provider.DriverOpenAICompat, - BaseURL: QiniuDefaultBaseURL, - Model: QiniuDefaultModel, - APIKeyEnv: QiniuDefaultAPIKeyEnv, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - Source: ProviderSourceBuiltin, - } -} - -// DefaultProviders returns all builtin provider definitions. -func DefaultProviders() []ProviderConfig { - return []ProviderConfig{ - OpenAIProvider(), - GeminiProvider(), - OpenLLProvider(), - QiniuProvider(), - } -} diff --git a/internal/config/builtin_providers_test.go b/internal/config/builtin_providers_test.go deleted file mode 100644 index b4ec7976..00000000 --- a/internal/config/builtin_providers_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package config - -import ( - "testing" -) - -func TestDefaultProvidersReturnsAllBuiltins(t *testing.T) { - t.Parallel() - - providers := DefaultProviders() - if len(providers) != 4 { - t.Fatalf("expected 4 builtin providers, got %d", len(providers)) - } - - expectedNames := []string{OpenAIName, GeminiName, OpenLLName, QiniuName} - for i, provider := range providers { - if provider.Name != expectedNames[i] { - t.Fatalf("expected provider[%d] name %q, got %q", i, expectedNames[i], provider.Name) - } - } -} - -func TestOpenAIProviderConfig(t *testing.T) { - t.Parallel() - - provider := OpenAIProvider() - - if provider.Name != OpenAIName { - t.Fatalf("expected name %q, got %q", OpenAIName, provider.Name) - } - if provider.Driver != "openaicompat" { - t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) - } - if provider.BaseURL != OpenAIDefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", OpenAIDefaultBaseURL, provider.BaseURL) - } - if provider.Model != OpenAIDefaultModel { - t.Fatalf("expected default model %q, got %q", OpenAIDefaultModel, provider.Model) - } - if provider.APIKeyEnv != OpenAIDefaultAPIKeyEnv { - t.Fatalf("expected API key env %q, got %q", OpenAIDefaultAPIKeyEnv, provider.APIKeyEnv) - } -} - -func TestGeminiProviderConfig(t *testing.T) { - t.Parallel() - - provider := GeminiProvider() - - if provider.Name != GeminiName { - t.Fatalf("expected name %q, got %q", GeminiName, provider.Name) - } - if provider.Driver != "openaicompat" { - t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) - } - if provider.BaseURL != GeminiDefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", GeminiDefaultBaseURL, provider.BaseURL) - } - if provider.Model != GeminiDefaultModel { - t.Fatalf("expected default model %q, got %q", GeminiDefaultModel, provider.Model) - } - if provider.APIKeyEnv != GeminiDefaultAPIKeyEnv { - t.Fatalf("expected API key env %q, got %q", GeminiDefaultAPIKeyEnv, provider.APIKeyEnv) - } -} - -func TestOpenLLProviderConfig(t *testing.T) { - t.Parallel() - - provider := OpenLLProvider() - - if provider.Name != OpenLLName { - t.Fatalf("expected name %q, got %q", OpenLLName, provider.Name) - } - if provider.Driver != "openaicompat" { - t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) - } - if provider.BaseURL != OpenLLDefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", OpenLLDefaultBaseURL, provider.BaseURL) - } - if provider.Model != OpenLLDefaultModel { - t.Fatalf("expected default model %q, got %q", OpenLLDefaultModel, provider.Model) - } - if provider.APIKeyEnv != OpenLLDefaultAPIKeyEnv { - t.Fatalf("expected API key env %q, got %q", OpenLLDefaultAPIKeyEnv, provider.APIKeyEnv) - } -} - -func TestProviderDefaultsAreIndependent(t *testing.T) { - t.Parallel() - - provider1 := OpenAIProvider() - provider1.Model = "modified-model" - - provider2 := OpenAIProvider() - if provider2.Model == "modified-model" { - t.Fatal("expected builtin provider defaults to be independent between calls") - } - if provider2.Model != OpenAIDefaultModel { - t.Fatalf("expected default model %q, got %q", OpenAIDefaultModel, provider2.Model) - } -} - -func TestDefaultProvidersReturnsIndependentSlices(t *testing.T) { - t.Parallel() - - providers1 := DefaultProviders() - providers1[0].Name = "modified" - - providers2 := DefaultProviders() - if providers2[0].Name == "modified" { - t.Fatal("expected DefaultProviders to return independent slices") - } - if providers2[0].Name != OpenAIName { - t.Fatalf("expected first provider name %q, got %q", OpenAIName, providers2[0].Name) - } -} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..bc4a882f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,185 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + goruntime "runtime" + "strings" +) + +const ( + DefaultWorkdir = "." + DefaultMaxLoops = 8 + DefaultToolTimeoutSec = 20 +) + +type Config struct { + Providers []ProviderConfig `yaml:"-"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + Workdir string `yaml:"-"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Context ContextConfig `yaml:"context,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` + Memo MemoConfig `yaml:"memo,omitempty"` +} + +// StaticDefaults 返回 config 层负责的静态默认值骨架,不包含 provider 装配和选择状态修复。 +func StaticDefaults() *Config { + return &Config{ + Workdir: DefaultWorkdir, + Shell: defaultShell(), + MaxLoops: DefaultMaxLoops, + ToolTimeoutSec: DefaultToolTimeoutSec, + Context: defaultContextConfig(), + Tools: ToolsConfig{ + WebFetch: defaultWebFetchConfig(), + MCP: defaultMCPConfig(), + }, + Memo: defaultMemoConfig(), + } +} + +func (c *Config) Clone() Config { + if c == nil { + return *StaticDefaults() + } + + clone := *c + clone.Providers = cloneProviders(c.Providers) + clone.Context = c.Context.Clone() + clone.Tools = c.Tools.Clone() + clone.Memo = c.Memo.Clone() + return clone +} + +// applyStaticDefaults 仅补齐静态配置默认值 +func (c *Config) applyStaticDefaults(defaults Config) { + if c == nil { + return + } + + if strings.TrimSpace(c.Workdir) == "" { + c.Workdir = defaults.Workdir + } + if strings.TrimSpace(c.Shell) == "" { + c.Shell = defaults.Shell + } + if c.MaxLoops <= 0 { + c.MaxLoops = defaults.MaxLoops + } + if c.ToolTimeoutSec <= 0 { + c.ToolTimeoutSec = defaults.ToolTimeoutSec + } + c.Context.ApplyDefaults(defaults.Context) + c.Tools.ApplyDefaults(defaults.Tools) + c.Memo.ApplyDefaults(defaults.Memo) + + c.Workdir = normalizeWorkdir(c.Workdir) +} + +// ValidateSnapshot 校验配置快照本身是否结构完整 +func (c *Config) ValidateSnapshot() error { + if c == nil { + return errors.New("config: config is nil") + } + if len(c.Providers) == 0 { + return errors.New("config: providers is empty") + } + + seen := make(map[string]struct{}, len(c.Providers)) + seenEndpoints := make(map[string]string, len(c.Providers)) + for i, provider := range c.Providers { + if err := provider.Validate(); err != nil { + return fmt.Errorf("config: provider[%d]: %w", i, err) + } + + key := normalizeProviderName(provider.Name) + if _, exists := seen[key]; exists { + return fmt.Errorf("config: duplicate provider name %q", provider.Name) + } + seen[key] = struct{}{} + + identity, err := provider.Identity() + if err != nil { + return fmt.Errorf("config: provider[%d]: %w", i, err) + } + if existingName, exists := seenEndpoints[identity.Key()]; exists { + return fmt.Errorf( + "config: duplicate provider endpoint %q for providers %q and %q", + identity.String(), + existingName, + provider.Name, + ) + } + seenEndpoints[identity.Key()] = provider.Name + } + + if strings.TrimSpace(c.Workdir) == "" { + return errors.New("config: workdir is empty") + } + if !filepath.IsAbs(c.Workdir) { + return fmt.Errorf("config: workdir must be absolute, got %q", c.Workdir) + } + if err := c.Tools.Validate(); err != nil { + return fmt.Errorf("config: tools: %w", err) + } + if err := c.Context.Validate(); err != nil { + return fmt.Errorf("config: context: %w", err) + } + if err := c.Memo.Validate(); err != nil { + return fmt.Errorf("config: memo: %w", err) + } + + return nil +} + +func (c *Config) ProviderByName(name string) (ProviderConfig, error) { + if c == nil { + return ProviderConfig{}, errors.New("config: config is nil") + } + + target := normalizeProviderName(name) + for _, provider := range c.Providers { + if normalizeProviderName(provider.Name) == target { + return provider, nil + } + } + + return ProviderConfig{}, fmt.Errorf("config: provider %q not found", name) +} + +func normalizeWorkdir(workdir string) string { + workdir = strings.TrimSpace(workdir) + if workdir == "" { + return "" + } + + if workdir == "." { + if wd, err := os.Getwd(); err == nil { + return wd + } + return workdir + } + + if filepath.IsAbs(workdir) { + return filepath.Clean(workdir) + } + + if wd, err := os.Getwd(); err == nil { + return filepath.Clean(filepath.Join(wd, workdir)) + } + + return filepath.Clean(workdir) +} + +func defaultShell() string { + if goruntime.GOOS == "windows" { + return "powershell" + } + return "bash" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a05358df..83c37cbf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1429,3 +1429,356 @@ func TestMemoConfigValidate(t *testing.T) { } }) } + +func TestNormalizeWorkdirEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + check func(t *testing.T, value string) + }{ + { + name: "empty string returns empty", + input: "", + check: func(t *testing.T, value string) { + if value != "" { + t.Fatalf("expected empty, got %q", value) + } + }, + }, + { + name: "whitespace only returns empty", + input: " ", + check: func(t *testing.T, value string) { + if value != "" { + t.Fatalf("expected empty for whitespace-only input, got %q", value) + } + }, + }, + { + name: "dot resolves to cwd", + input: ".", + check: func(t *testing.T, value string) { + if !filepath.IsAbs(value) { + t.Fatalf("expected absolute path for dot, got %q", value) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := normalizeWorkdir(tt.input) + tt.check(t, got) + }) + } +} + +func TestStaticReturnsCompleteSkeleton(t *testing.T) { + t.Parallel() + + cfg := StaticDefaults() + if cfg == nil { + t.Fatal("expected non-nil static defaults") + } + if cfg.Workdir == "" { + t.Fatal("expected workdir to be set") + } + if cfg.Shell == "" { + t.Fatal("expected shell to be set") + } + if cfg.MaxLoops == 0 { + t.Fatal("expected max_loops to be set") + } + if cfg.ToolTimeoutSec == 0 { + t.Fatal("expected tool_timeout_sec to be set") + } + if cfg.Tools.WebFetch.MaxResponseBytes == 0 { + t.Fatal("expected webfetch max_response_bytes to be set") + } +} + +func TestApplyDefaultsNilReceivers(t *testing.T) { + t.Parallel() + + var toolsCfg *ToolsConfig + toolsCfg.ApplyDefaults(ToolsConfig{}) + + var ctxCfg *ContextConfig + ctxCfg.ApplyDefaults(ContextConfig{}) + + var wfCfg *WebFetchConfig + wfCfg.ApplyDefaults(WebFetchConfig{}) +} + +func TestApplyStaticDefaultsNilReceiver(t *testing.T) { + t.Parallel() + + var cfg *Config + cfg.applyStaticDefaults(*StaticDefaults()) + if cfg != nil { + t.Fatal("expected nil config to remain nil") + } +} + +func TestValidateSnapshotRejectsEmptyWorkdir(t *testing.T) { + t.Parallel() + + cfg := Config{ + Providers: []ProviderConfig{testDefaultProviderConfig()}, + SelectedProvider: testProviderName, + CurrentModel: testModel, + Workdir: "", + Shell: "powershell", + } + err := cfg.ValidateSnapshot() + if err == nil || !strings.Contains(err.Error(), "workdir is empty") { + t.Fatalf("expected empty workdir error, got %v", err) + } +} + +func TestValidateSnapshotPropagatesCompactError(t *testing.T) { + t.Parallel() + + workdir := filepath.Clean(t.TempDir()) + cfg := Config{ + Providers: []ProviderConfig{testDefaultProviderConfig()}, + SelectedProvider: testProviderName, + CurrentModel: testModel, + Workdir: workdir, + Shell: "powershell", + Tools: ToolsConfig{ + WebFetch: WebFetchConfig{ + MaxResponseBytes: DefaultWebFetchMaxResponseBytes, + SupportedContentTypes: []string{"text/html"}, + }, + }, + Context: ContextConfig{ + Compact: CompactConfig{ + ManualStrategy: "invalid_strategy", + ManualKeepRecentMessages: 10, + MaxSummaryChars: 1200, + }, + }, + } + err := cfg.ValidateSnapshot() + if err == nil || !strings.Contains(err.Error(), "compact") { + t.Fatalf("expected compact error, got %v", err) + } +} + +func TestManagerUpdateNilMutateFunc(t *testing.T) { + tempDir := t.TempDir() + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + err := manager.Update(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "mutate func is nil") { + t.Fatalf("expected nil mutate error, got %v", err) + } +} + +func TestManagerUpdateValidationFailurePreservesCurrentState(t *testing.T) { + tempDir := t.TempDir() + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + before := manager.Get() + err := manager.Update(context.Background(), func(cfg *Config) error { + cfg.Context.Compact.ManualStrategy = "totally_invalid_strategy" + return nil + }) + if err == nil { + t.Fatal("expected validation error") + } + + after := manager.Get() + if before.SelectedProvider != after.SelectedProvider { + t.Fatalf("expected selected provider to be preserved after failed update") + } +} + +func TestParseConfigWithEmptyData(t *testing.T) { + t.Parallel() + + cfg, err := parseConfigWithContextDefaults([]byte{}, StaticDefaults().Context) + if err != nil { + t.Fatalf("parseConfigWithContextDefaults(empty) error = %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config for empty data") + } +} + +func TestParseConfigWithWhitespaceOnlyData(t *testing.T) { + t.Parallel() + + cfg, err := parseConfigWithContextDefaults([]byte(" \n\t "), StaticDefaults().Context) + if err != nil { + t.Fatalf("parseConfigWithContextDefaults(whitespace) error = %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config for whitespace-only data") + } +} + +func TestMarshalPersistedConfigEndsWithNewline(t *testing.T) { + t.Parallel() + + snapshot := testDefaultConfig().Clone() + data, err := marshalPersistedConfig(snapshot) + if err != nil { + t.Fatalf("marshalPersistedConfig() error = %v", err) + } + if len(data) == 0 { + t.Fatal("expected non-empty marshaled data") + } + if data[len(data)-1] != '\n' { + t.Fatal("expected marshaled data to end with newline") + } +} + +func TestAssembleProvidersAcceptsEmptyNameProvider(t *testing.T) { + t.Parallel() + + custom := []ProviderConfig{{ + Name: "", + Driver: "custom-driver", + BaseURL: "https://example.com/v1", + APIKeyEnv: "CUSTOM_KEY", + Source: ProviderSourceCustom, + }} + + assembled, err := assembleProviders(testDefaultConfig().Providers, custom) + if err != nil { + t.Fatalf("assembleProviders() with empty name error = %v", err) + } + if len(assembled) < 2 { + t.Fatalf("expected at least 2 providers, got %d", len(assembled)) + } +} + +func TestAssembleProvidersDuplicateBetweenBuiltinAndCustom(t *testing.T) { + t.Parallel() + + custom := []ProviderConfig{{ + Name: testProviderName, + Driver: "custom-driver", + BaseURL: "https://example.com/v1", + APIKeyEnv: "CUSTOM_KEY", + Source: ProviderSourceCustom, + }} + + _, err := assembleProviders(testDefaultConfig().Providers, custom) + if err == nil || !strings.Contains(err.Error(), "duplicate provider name") { + t.Fatalf("expected duplicate error between builtin/custom, got %v", err) + } +} + +func TestToRuntimeConfigMapsAllFields(t *testing.T) { + t.Parallel() + + resolved := ResolvedProviderConfig{ + ProviderConfig: ProviderConfig{ + Name: "test-provider", + Driver: "gemini", + BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + Model: "gemini-2.5-flash", + APIKeyEnv: "TEST_ENV_KEY", + APIStyle: "responses", + DeploymentMode: "vertex", + APIVersion: "v1beta", + }, + APIKey: "resolved-secret-key", + } + + got := resolved.ToRuntimeConfig() + if got.Name != "test-provider" { + t.Fatalf("expected Name=test-provider, got %q", got.Name) + } + if got.Driver != "gemini" { + t.Fatalf("expected Driver=gemini, got %q", got.Driver) + } + if got.DefaultModel != "gemini-2.5-flash" { + t.Fatalf("expected DefaultModel=gemini-2.5-flash, got %q", got.DefaultModel) + } + if got.APIKey != "resolved-secret-key" { + t.Fatalf("expected APIKey=resolved-secret-key, got %q", got.APIKey) + } + if got.DeploymentMode != "vertex" { + t.Fatalf("expected DeploymentMode=vertex, got %q", got.DeploymentMode) + } + if got.APIVersion != "v1beta" { + t.Fatalf("expected APIVersion=v1beta, got %q", got.APIVersion) + } +} + +func TestLoaderLoadWithCanceledContext(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := loader.Load(ctx) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled error for Load, got %v", err) + } +} + +func TestLoaderSaveWithCanceledContext(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + cfg := testDefaultConfig() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := loader.Save(ctx, cfg) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled error for Save, got %v", err) + } +} + +func TestManagerUpdateRestoresProvidersWhenCleared(t *testing.T) { + tempDir := t.TempDir() + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + err := manager.Update(context.Background(), func(cfg *Config) error { + cfg.Providers = nil + return nil + }) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + reloaded := manager.Get() + if len(reloaded.Providers) == 0 { + t.Fatal("expected providers to be restored from defaults after clearing") + } +} + +func TestConfigCloneNilReceiverReturnsDefaults(t *testing.T) { + t.Parallel() + + var cfg *Config + cloned := cfg.Clone() + if cloned.Workdir == "" { + t.Fatal("expected cloned nil config to have default workdir") + } + if cloned.Shell == "" { + t.Fatal("expected cloned nil config to have default shell") + } + if cloned.MaxLoops == 0 { + t.Fatal("expected cloned nil config to have default max_loops") + } +} diff --git a/internal/config/context.go b/internal/config/context.go new file mode 100644 index 00000000..9d28c533 --- /dev/null +++ b/internal/config/context.go @@ -0,0 +1,148 @@ +package config + +import ( + "errors" + "fmt" + "strings" +) + +const ( + DefaultCompactManualKeepRecentMessages = 10 + DefaultCompactMaxSummaryChars = 1200 + DefaultAutoCompactInputTokenThreshold = 100000 + + CompactManualStrategyKeepRecent = "keep_recent" + CompactManualStrategyFullReplace = "full_replace" +) + +type ContextConfig struct { + Compact CompactConfig `yaml:"compact,omitempty"` + AutoCompact AutoCompactConfig `yaml:"auto_compact,omitempty"` +} + +type CompactConfig struct { + ManualStrategy string `yaml:"manual_strategy,omitempty"` + ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"` + MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` + MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` +} + +// AutoCompactConfig controls automatic context compression triggered by token thresholds. +type AutoCompactConfig struct { + Enabled bool `yaml:"enabled"` + InputTokenThreshold int `yaml:"input_token_threshold,omitempty"` +} + +// defaultContextConfig 返回上下文压缩相关配置的默认值。 +func defaultContextConfig() ContextConfig { + return ContextConfig{ + Compact: defaultCompactConfig(), + AutoCompact: defaultAutoCompactConfig(), + } +} + +func defaultAutoCompactConfig() AutoCompactConfig { + return AutoCompactConfig{ + InputTokenThreshold: DefaultAutoCompactInputTokenThreshold, + } +} + +// defaultCompactConfig 返回手动 compact 策略的默认配置。 +func defaultCompactConfig() CompactConfig { + return CompactConfig{ + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: DefaultCompactManualKeepRecentMessages, + MaxSummaryChars: DefaultCompactMaxSummaryChars, + } +} + +// Clone 返回上下文配置的独立副本,避免后续修改污染原值。 +func (c ContextConfig) Clone() ContextConfig { + return ContextConfig{ + Compact: c.Compact.Clone(), + AutoCompact: c.AutoCompact.Clone(), + } +} + +// Clone 返回 compact 配置的值副本。 +func (c CompactConfig) Clone() CompactConfig { + return c +} + +// Clone 返回 auto_compact 配置的值副本。 +func (c AutoCompactConfig) Clone() AutoCompactConfig { + return c +} + +// ApplyDefaults 为上下文配置补齐缺省的 compact 参数。 +func (c *ContextConfig) ApplyDefaults(defaults ContextConfig) { + if c == nil { + return + } + + c.Compact.ApplyDefaults(defaults.Compact) + c.AutoCompact.ApplyDefaults(defaults.AutoCompact) +} + +// ApplyDefaults 为 compact 配置填充缺省策略和阈值。 +func (c *CompactConfig) ApplyDefaults(defaults CompactConfig) { + if c == nil { + return + } + + if strings.TrimSpace(c.ManualStrategy) == "" { + c.ManualStrategy = defaults.ManualStrategy + } + if c.ManualKeepRecentMessages <= 0 { + c.ManualKeepRecentMessages = defaults.ManualKeepRecentMessages + } + if c.MaxSummaryChars <= 0 { + c.MaxSummaryChars = defaults.MaxSummaryChars + } +} + +// ApplyDefaults 为 auto_compact 配置填充缺省阈值。 +func (c *AutoCompactConfig) ApplyDefaults(defaults AutoCompactConfig) { + if c == nil { + return + } + if c.InputTokenThreshold <= 0 { + c.InputTokenThreshold = defaults.InputTokenThreshold + } +} + +// Validate 校验上下文压缩配置是否合法。 +func (c ContextConfig) Validate() error { + if err := c.Compact.Validate(); err != nil { + return fmt.Errorf("compact: %w", err) + } + if err := c.AutoCompact.Validate(); err != nil { + return fmt.Errorf("auto_compact: %w", err) + } + return nil +} + +// Validate 校验 compact 配置中的策略和阈值是否可用。 +func (c CompactConfig) Validate() error { + if c.ManualKeepRecentMessages <= 0 { + return errors.New("manual_keep_recent_messages must be greater than 0") + } + if c.MaxSummaryChars <= 0 { + return errors.New("max_summary_chars must be greater than 0") + } + + switch strings.ToLower(strings.TrimSpace(c.ManualStrategy)) { + case CompactManualStrategyKeepRecent, CompactManualStrategyFullReplace: + return nil + default: + return fmt.Errorf("manual_strategy %q is not supported", c.ManualStrategy) + } +} + +// Validate 校验 auto_compact 配置是否合法。 +func (c AutoCompactConfig) Validate() error { + if c.Enabled && c.InputTokenThreshold <= 0 { + return errors.New("input_token_threshold must be greater than 0 when enabled") + } + return nil +} diff --git a/internal/config/context_test.go b/internal/config/context_test.go new file mode 100644 index 00000000..909d42f7 --- /dev/null +++ b/internal/config/context_test.go @@ -0,0 +1,112 @@ +package config + +import ( + "strings" + "testing" +) + +func TestContextConfigCloneIndependence(t *testing.T) { + t.Parallel() + + original := ContextConfig{ + Compact: CompactConfig{ + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 10, + MaxSummaryChars: 1200, + }, + AutoCompact: AutoCompactConfig{ + Enabled: true, + InputTokenThreshold: 50000, + }, + } + cloned := original.Clone() + + cloned.Compact.ManualStrategy = CompactManualStrategyFullReplace + cloned.Compact.ManualKeepRecentMessages = 5 + cloned.AutoCompact.Enabled = false + cloned.AutoCompact.InputTokenThreshold = 100000 + + if original.Compact.ManualStrategy == cloned.Compact.ManualStrategy { + t.Fatal("expected Compact Clone to be independent") + } + if original.Compact.ManualKeepRecentMessages == cloned.Compact.ManualKeepRecentMessages { + t.Fatal("expected ManualKeepRecentMessages Clone to be independent") + } + if original.AutoCompact.Enabled == cloned.AutoCompact.Enabled { + t.Fatal("expected AutoCompact Enabled clone to be independent") + } + if original.AutoCompact.InputTokenThreshold == cloned.AutoCompact.InputTokenThreshold { + t.Fatal("expected AutoCompact InputTokenThreshold clone to be independent") + } +} + +func TestCompactConfigCloneValueSemantics(t *testing.T) { + t.Parallel() + + original := CompactConfig{ + ManualStrategy: CompactManualStrategyFullReplace, + ManualKeepRecentMessages: 5, + MaxSummaryChars: 800, + MicroCompactDisabled: true, + } + cloned := original.Clone() + if original != cloned { + t.Fatalf("expected equal configs, got %+v vs %+v", original, cloned) + } +} + +func TestAutoCompactConfigCloneValueSemantics(t *testing.T) { + t.Parallel() + + original := AutoCompactConfig{Enabled: true, InputTokenThreshold: 75000} + cloned := original.Clone() + if original != cloned { + t.Fatalf("expected equal configs, got %+v vs %+v", original, cloned) + } +} + +func TestContextConfigValidatePropagatesCompactError(t *testing.T) { + t.Parallel() + + cfg := ContextConfig{ + Compact: CompactConfig{ + ManualKeepRecentMessages: 0, + MaxSummaryChars: 0, + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "compact") { + t.Fatalf("expected compact validation error, got %v", err) + } +} + +func TestContextConfigApplyDefaultsNilReceiver(t *testing.T) { + t.Parallel() + + var ctxCfg *ContextConfig + ctxCfg.ApplyDefaults(ContextConfig{ + Compact: CompactConfig{ManualStrategy: CompactManualStrategyFullReplace}, + AutoCompact: AutoCompactConfig{InputTokenThreshold: 50000}, + }) +} + +func TestCompactConfigApplyDefaultsAllZeroValues(t *testing.T) { + t.Parallel() + + cfg := CompactConfig{} + defaults := CompactConfig{ + ManualStrategy: CompactManualStrategyFullReplace, + ManualKeepRecentMessages: 15, + MaxSummaryChars: 2000, + } + cfg.ApplyDefaults(defaults) + if cfg.ManualStrategy != CompactManualStrategyFullReplace { + t.Fatalf("expected strategy %q, got %q", CompactManualStrategyFullReplace, cfg.ManualStrategy) + } + if cfg.ManualKeepRecentMessages != 15 { + t.Fatalf("expected messages=15, got %d", cfg.ManualKeepRecentMessages) + } + if cfg.MaxSummaryChars != 2000 { + t.Fatalf("expected chars=2000, got %d", cfg.MaxSummaryChars) + } +} diff --git a/internal/config/mcp.go b/internal/config/mcp.go new file mode 100644 index 00000000..cd6790c6 --- /dev/null +++ b/internal/config/mcp.go @@ -0,0 +1,246 @@ +package config + +import ( + "fmt" + "strings" +) + +type MCPConfig struct { + Servers []MCPServerConfig `yaml:"servers,omitempty"` + Exposure MCPExposureConfig `yaml:"exposure,omitempty"` +} + +type MCPExposureConfig struct { + Allowlist []string `yaml:"allowlist,omitempty"` + Denylist []string `yaml:"denylist,omitempty"` + Agents []MCPAgentExposureConfig `yaml:"agents,omitempty"` +} + +type MCPAgentExposureConfig struct { + Agent string `yaml:"agent"` + Allowlist []string `yaml:"allowlist,omitempty"` +} + +type MCPServerConfig struct { + ID string `yaml:"id"` + Enabled bool `yaml:"enabled,omitempty"` + Source string `yaml:"source,omitempty"` + Version string `yaml:"version,omitempty"` + Stdio MCPStdioConfig `yaml:"stdio,omitempty"` + Env []MCPEnvVarConfig `yaml:"env,omitempty"` +} + +type MCPStdioConfig struct { + Command string `yaml:"command,omitempty"` + Args []string `yaml:"args,omitempty"` + Workdir string `yaml:"workdir,omitempty"` + StartTimeoutSec int `yaml:"start_timeout_sec,omitempty"` + CallTimeoutSec int `yaml:"call_timeout_sec,omitempty"` + RestartBackoffSec int `yaml:"restart_backoff_sec,omitempty"` +} + +type MCPEnvVarConfig struct { + Name string `yaml:"name"` + Value string `yaml:"value,omitempty"` + ValueEnv string `yaml:"value_env,omitempty"` +} + +// defaultMCPConfig 返回 MCP 工具接入配置的默认值(默认无 server)。 +func defaultMCPConfig() MCPConfig { + return MCPConfig{ + Servers: nil, + } +} + +// Clone 返回 MCP 配置的独立副本,避免引用共享造成并发污染。 +func (c MCPConfig) Clone() MCPConfig { + cloned := MCPConfig{ + Exposure: c.Exposure.Clone(), + } + if len(c.Servers) == 0 { + return cloned + } + cloned.Servers = make([]MCPServerConfig, 0, len(c.Servers)) + for _, server := range c.Servers { + cloned.Servers = append(cloned.Servers, server.Clone()) + } + return cloned +} + +// Clone 返回单个 MCP server 配置的独立副本。 +func (c MCPServerConfig) Clone() MCPServerConfig { + cloned := c + cloned.Stdio.Args = append([]string(nil), c.Stdio.Args...) + if c.Env != nil { + cloned.Env = make([]MCPEnvVarConfig, len(c.Env)) + copy(cloned.Env, c.Env) + } else { + cloned.Env = nil + } + return cloned +} + +// ApplyDefaults 为 MCP 配置补齐缺省字段,保证运行时行为可预测。 +func (c *MCPConfig) ApplyDefaults(defaults MCPConfig) { + if c == nil { + return + } + if len(c.Servers) == 0 { + c.Servers = defaults.Clone().Servers + } + c.Exposure.ApplyDefaults() + for index := range c.Servers { + c.Servers[index].ApplyDefaults() + } +} + +// Validate 校验 MCP server 列表与字段合法性,防止启动后失败。 +func (c MCPConfig) Validate() error { + if err := c.Exposure.Validate(); err != nil { + return fmt.Errorf("exposure: %w", err) + } + if len(c.Servers) == 0 { + return nil + } + seen := make(map[string]struct{}, len(c.Servers)) + for index, server := range c.Servers { + normalizedID := strings.ToLower(strings.TrimSpace(server.ID)) + if normalizedID == "" { + return fmt.Errorf("servers[%d].id is empty", index) + } + if _, exists := seen[normalizedID]; exists { + return fmt.Errorf("duplicate servers[%d].id %q", index, server.ID) + } + seen[normalizedID] = struct{}{} + + source := strings.ToLower(strings.TrimSpace(server.Source)) + if source == "" { + source = "stdio" + } + if source != "stdio" { + return fmt.Errorf("servers[%d].source %q is not supported", index, server.Source) + } + if !server.Enabled { + continue + } + + if strings.TrimSpace(server.Stdio.Command) == "" { + return fmt.Errorf("servers[%d].stdio.command is empty", index) + } + for envIndex, env := range server.Env { + if strings.TrimSpace(env.Name) == "" { + return fmt.Errorf("servers[%d].env[%d].name is empty", index, envIndex) + } + hasValue := strings.TrimSpace(env.Value) != "" + hasValueEnv := strings.TrimSpace(env.ValueEnv) != "" + if hasValue == hasValueEnv { + return fmt.Errorf("servers[%d].env[%d] must set exactly one of value/value_env", index, envIndex) + } + } + } + return nil +} + +// Clone 返回 MCP 工具暴露过滤配置的独立副本。 +func (c MCPExposureConfig) Clone() MCPExposureConfig { + cloned := MCPExposureConfig{ + Allowlist: append([]string(nil), c.Allowlist...), + Denylist: append([]string(nil), c.Denylist...), + } + if len(c.Agents) > 0 { + cloned.Agents = make([]MCPAgentExposureConfig, 0, len(c.Agents)) + for _, agent := range c.Agents { + cloned.Agents = append(cloned.Agents, agent.Clone()) + } + } + return cloned +} + +// Clone 返回单条 agent 暴露规则的独立副本。 +func (c MCPAgentExposureConfig) Clone() MCPAgentExposureConfig { + cloned := c + cloned.Allowlist = append([]string(nil), c.Allowlist...) + return cloned +} + +// ApplyDefaults 规范化 MCP 工具暴露过滤配置。 +func (c *MCPExposureConfig) ApplyDefaults() { + if c == nil { + return + } + c.Allowlist = normalizePatternList(c.Allowlist) + c.Denylist = normalizePatternList(c.Denylist) + for index := range c.Agents { + c.Agents[index].ApplyDefaults() + } +} + +// ApplyDefaults 规范化单条 agent 暴露规则。 +func (c *MCPAgentExposureConfig) ApplyDefaults() { + if c == nil { + return + } + c.Agent = strings.TrimSpace(c.Agent) + c.Allowlist = normalizePatternList(c.Allowlist) +} + +// Validate 校验 MCP 暴露过滤配置合法性。 +func (c MCPExposureConfig) Validate() error { + seenAgents := make(map[string]struct{}, len(c.Agents)) + for index, agent := range c.Agents { + normalizedAgent := strings.ToLower(strings.TrimSpace(agent.Agent)) + if normalizedAgent == "" { + return fmt.Errorf("agents[%d].agent is empty", index) + } + if _, exists := seenAgents[normalizedAgent]; exists { + return fmt.Errorf("duplicate agents[%d].agent %q", index, agent.Agent) + } + seenAgents[normalizedAgent] = struct{}{} + for allowIndex, pattern := range agent.Allowlist { + if strings.TrimSpace(pattern) == "" { + return fmt.Errorf("agents[%d].allowlist[%d] is empty", index, allowIndex) + } + } + } + for index, pattern := range c.Allowlist { + if strings.TrimSpace(pattern) == "" { + return fmt.Errorf("allowlist[%d] is empty", index) + } + } + for index, pattern := range c.Denylist { + if strings.TrimSpace(pattern) == "" { + return fmt.Errorf("denylist[%d] is empty", index) + } + } + return nil +} + +// ApplyDefaults 为 MCP server stdio 配置补齐默认值。 +func (c *MCPServerConfig) ApplyDefaults() { + if c == nil { + return + } + c.Source = strings.ToLower(strings.TrimSpace(c.Source)) + if c.Source == "" { + c.Source = "stdio" + } +} + +// normalizePatternList 规范化暴露过滤模式列表并剔除空项。 +func normalizePatternList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/internal/config/mcp_test.go b/internal/config/mcp_test.go new file mode 100644 index 00000000..397ec982 --- /dev/null +++ b/internal/config/mcp_test.go @@ -0,0 +1,295 @@ +package config + +import ( + "strings" + "testing" +) + +func TestMCPServerConfigApplyDefaultsNormalizesSource(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + }{ + {name: "trimmed", source: " STDIO "}, + {name: "empty", source: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := MCPServerConfig{ + ID: "test-server", + Enabled: true, + Source: tt.source, + Stdio: MCPStdioConfig{Command: "cmd"}, + } + cfg.ApplyDefaults() + if cfg.Source != "stdio" { + t.Fatalf("expected normalized source 'stdio', got %q", cfg.Source) + } + }) + } +} + +func TestMCPConfigValidateSkipsDisabledServerWithoutCommand(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Servers: []MCPServerConfig{ + {ID: "disabled-no-cmd", Enabled: false, Source: "stdio"}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("expected disabled server without command to pass validation, got %v", err) + } +} + +func TestMCPConfigValidateRejectsServerWithEmptyEnvBothValues(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Servers: []MCPServerConfig{ + { + ID: "test", + Enabled: true, + Source: "stdio", + Stdio: MCPStdioConfig{Command: "cmd"}, + Env: []MCPEnvVarConfig{ + {Name: "VAR", Value: "", ValueEnv: ""}, + {Name: "VAR2", Value: "a", ValueEnv: "b"}, + }, + }, + }, + } + err := cfg.Validate() + if err == nil { + t.Fatal("expected validation error for invalid env bindings") + } +} + +func TestMCPConfigValidateRejectsServerWithMissingEnvName(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Servers: []MCPServerConfig{ + { + ID: "test", + Enabled: true, + Source: "stdio", + Stdio: MCPStdioConfig{Command: "cmd"}, + Env: []MCPEnvVarConfig{ + {Name: " ", Value: "a"}, + }, + }, + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "name is empty") { + t.Fatalf("expected empty env name error, got %v", err) + } +} + +func TestMCPServerConfigCloneWithoutEnv(t *testing.T) { + t.Parallel() + + original := MCPServerConfig{ + ID: "test", + Enabled: true, + Source: "stdio", + Stdio: MCPStdioConfig{Command: "cmd", Args: []string{"--help"}}, + Env: nil, + } + cloned := original.Clone() + cloned.Stdio.Args[0] = "--version" + if original.Stdio.Args[0] == cloned.Stdio.Args[0] { + t.Fatal("expected args clone to be independent") + } +} + +func TestMCPServerConfigCloneWithEmptyEnvSlice(t *testing.T) { + t.Parallel() + + original := MCPServerConfig{ + ID: "test", + Stdio: MCPStdioConfig{Command: "cmd"}, + Env: []MCPEnvVarConfig{}, + } + cloned := original.Clone() + if cloned.Env == nil { + t.Fatal("expected cloned env to preserve empty slice") + } + if len(cloned.Env) != 0 { + t.Fatalf("expected cloned env length to be 0, got %d", len(cloned.Env)) + } +} + +func TestMCPServerConfigCloneEnvIndependence(t *testing.T) { + t.Parallel() + + original := MCPServerConfig{ + ID: "test", + Stdio: MCPStdioConfig{Command: "cmd"}, + Env: []MCPEnvVarConfig{ + {Name: "TOKEN", Value: "secret"}, + }, + } + cloned := original.Clone() + cloned.Env[0].Value = "modified" + if original.Env[0].Value == cloned.Env[0].Value { + t.Fatal("expected Env clone to be independent") + } +} + +func TestMCPExposureConfigCloneIndependence(t *testing.T) { + t.Parallel() + + original := MCPExposureConfig{ + Allowlist: []string{"docs"}, + Denylist: []string{"admin.secret"}, + Agents: []MCPAgentExposureConfig{ + {Agent: "coder", Allowlist: []string{"docs.search"}}, + }, + } + + cloned := original.Clone() + cloned.Allowlist[0] = "changed" + cloned.Denylist[0] = "changed" + cloned.Agents[0].Agent = "planner" + cloned.Agents[0].Allowlist[0] = "changed" + + if original.Allowlist[0] != "docs" || original.Denylist[0] != "admin.secret" { + t.Fatalf("expected top-level slices to remain unchanged, got %+v", original) + } + if original.Agents[0].Agent != "coder" || original.Agents[0].Allowlist[0] != "docs.search" { + t.Fatalf("expected agent clone independence, got %+v", original.Agents[0]) + } +} + +func TestMCPExposureConfigApplyDefaultsNormalizesValues(t *testing.T) { + t.Parallel() + + cfg := MCPExposureConfig{ + Allowlist: []string{" Docs ", "", "MCP.Search.Live"}, + Denylist: []string{" Admin.Secret "}, + Agents: []MCPAgentExposureConfig{ + {Agent: " Planner ", Allowlist: []string{" Docs.Search ", ""}}, + }, + } + + cfg.ApplyDefaults() + if strings.Join(cfg.Allowlist, ",") != "docs,mcp.search.live" { + t.Fatalf("unexpected allowlist normalization: %+v", cfg.Allowlist) + } + if strings.Join(cfg.Denylist, ",") != "admin.secret" { + t.Fatalf("unexpected denylist normalization: %+v", cfg.Denylist) + } + if cfg.Agents[0].Agent != "Planner" { + t.Fatalf("expected agent to keep trimmed original casing, got %q", cfg.Agents[0].Agent) + } + if strings.Join(cfg.Agents[0].Allowlist, ",") != "docs.search" { + t.Fatalf("unexpected agent allowlist normalization: %+v", cfg.Agents[0].Allowlist) + } +} + +func TestMCPExposureConfigValidateRejectsInvalidRules(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg MCPExposureConfig + want string + }{ + { + name: "empty agent", + cfg: MCPExposureConfig{ + Agents: []MCPAgentExposureConfig{{Agent: " ", Allowlist: []string{"docs"}}}, + }, + want: "agents[0].agent is empty", + }, + { + name: "duplicate agent", + cfg: MCPExposureConfig{ + Agents: []MCPAgentExposureConfig{ + {Agent: "coder", Allowlist: []string{"docs"}}, + {Agent: "CoDeR", Allowlist: []string{"search"}}, + }, + }, + want: "duplicate agents[1].agent", + }, + { + name: "empty allowlist item", + cfg: MCPExposureConfig{Allowlist: []string{"docs", " "}}, + want: "allowlist[1] is empty", + }, + { + name: "empty denylist item", + cfg: MCPExposureConfig{Denylist: []string{" "}}, + want: "denylist[0] is empty", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.cfg.Validate() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected %q, got %v", tt.want, err) + } + }) + } +} + +func TestMCPConfigClonePreservesExposureWithoutServers(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Exposure: MCPExposureConfig{ + Allowlist: []string{"docs"}, + Agents: []MCPAgentExposureConfig{ + {Agent: "coder", Allowlist: []string{"docs.search"}}, + }, + }, + } + + cloned := cfg.Clone() + if strings.Join(cloned.Exposure.Allowlist, ",") != "docs" { + t.Fatalf("expected exposure allowlist cloned, got %+v", cloned.Exposure) + } + if len(cloned.Servers) != 0 { + t.Fatalf("expected no servers, got %+v", cloned.Servers) + } +} + +func TestMCPConfigValidateWrapsExposureErrors(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Exposure: MCPExposureConfig{ + Allowlist: []string{" "}, + }, + } + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "exposure: allowlist[0] is empty") { + t.Fatalf("expected wrapped exposure error, got %v", err) + } +} + +func TestMCPApplyDefaultsNilReceivers(t *testing.T) { + t.Parallel() + + var mcpCfg *MCPConfig + mcpCfg.ApplyDefaults(MCPConfig{}) + + var serverCfg *MCPServerConfig + serverCfg.ApplyDefaults() + + var expCfg *MCPExposureConfig + expCfg.ApplyDefaults() + + var agExpCfg *MCPAgentExposureConfig + agExpCfg.ApplyDefaults() +} diff --git a/internal/config/memo.go b/internal/config/memo.go new file mode 100644 index 00000000..d6c0f42e --- /dev/null +++ b/internal/config/memo.go @@ -0,0 +1,44 @@ +package config + +import "errors" + +const DefaultMemoMaxIndexLines = 200 + +// MemoConfig 控制跨会话持久记忆的行为配置。 +type MemoConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + AutoExtract bool `yaml:"auto_extract,omitempty"` + MaxIndexLines int `yaml:"max_index_lines,omitempty"` +} + +// defaultMemoConfig 返回跨会话记忆的默认配置。 +func defaultMemoConfig() MemoConfig { + return MemoConfig{ + Enabled: true, + AutoExtract: true, + MaxIndexLines: DefaultMemoMaxIndexLines, + } +} + +// Clone 返回 memo 配置的值副本。 +func (c MemoConfig) Clone() MemoConfig { + return c +} + +// ApplyDefaults 为 memo 配置补齐缺省参数。 +func (c *MemoConfig) ApplyDefaults(defaults MemoConfig) { + if c == nil { + return + } + if c.MaxIndexLines <= 0 { + c.MaxIndexLines = defaults.MaxIndexLines + } +} + +// Validate 校验 memo 配置是否合法。 +func (c MemoConfig) Validate() error { + if c.MaxIndexLines < 0 { + return errors.New("max_index_lines must be non-negative") + } + return nil +} diff --git a/internal/config/model.go b/internal/config/model.go deleted file mode 100644 index 9f0bb831..00000000 --- a/internal/config/model.go +++ /dev/null @@ -1,870 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "mime" - "os" - "path/filepath" - goruntime "runtime" - "strings" - - "neo-code/internal/provider" - providertypes "neo-code/internal/provider/types" -) - -const ( - DefaultWorkdir = "." - DefaultMaxLoops = 8 - DefaultToolTimeoutSec = 20 - DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 - DefaultCompactManualKeepRecentMessages = 10 - DefaultCompactMaxSummaryChars = 1200 - DefaultAutoCompactInputTokenThreshold = 100000 - DefaultMemoMaxIndexLines = 200 -) - -const ( - CompactManualStrategyKeepRecent = "keep_recent" - CompactManualStrategyFullReplace = "full_replace" -) - -var defaultWebFetchSupportedContentTypes = []string{ - "text/html", - "application/xhtml+xml", - "text/plain", - "application/json", - "application/xml", - "text/xml", -} - -type Config struct { - Providers []ProviderConfig `yaml:"-"` - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"-"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Context ContextConfig `yaml:"context,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` - Memo MemoConfig `yaml:"memo,omitempty"` -} - -type ProviderSource string - -const ( - ProviderSourceBuiltin ProviderSource = "builtin" - ProviderSourceCustom ProviderSource = "custom" -) - -type ProviderConfig struct { - Name string `yaml:"name"` - Driver string `yaml:"driver"` - BaseURL string `yaml:"base_url"` - Model string `yaml:"model"` - APIKeyEnv string `yaml:"api_key_env"` - APIStyle string `yaml:"-"` - DeploymentMode string `yaml:"-"` - APIVersion string `yaml:"-"` - Models []providertypes.ModelDescriptor `yaml:"-"` - Source ProviderSource `yaml:"-"` -} - -type ResolvedProviderConfig struct { - ProviderConfig - APIKey string `yaml:"-"` -} - -type ToolsConfig struct { - WebFetch WebFetchConfig `yaml:"webfetch,omitempty"` - MCP MCPConfig `yaml:"mcp,omitempty"` -} - -type ContextConfig struct { - Compact CompactConfig `yaml:"compact,omitempty"` - AutoCompact AutoCompactConfig `yaml:"auto_compact,omitempty"` -} - -// AutoCompactConfig controls automatic context compression triggered by token thresholds. -type AutoCompactConfig struct { - Enabled bool `yaml:"enabled"` - InputTokenThreshold int `yaml:"input_token_threshold,omitempty"` -} - -// MemoConfig 控制跨会话持久记忆的行为配置。 -type MemoConfig struct { - Enabled bool `yaml:"enabled,omitempty"` - AutoExtract bool `yaml:"auto_extract,omitempty"` - MaxIndexLines int `yaml:"max_index_lines,omitempty"` -} - -type CompactConfig struct { - ManualStrategy string `yaml:"manual_strategy,omitempty"` - ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"` - MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` - MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` -} - -type WebFetchConfig struct { - MaxResponseBytes int64 `yaml:"max_response_bytes,omitempty"` - SupportedContentTypes []string `yaml:"supported_content_types,omitempty"` -} - -type MCPConfig struct { - Servers []MCPServerConfig `yaml:"servers,omitempty"` - Exposure MCPExposureConfig `yaml:"exposure,omitempty"` -} - -type MCPExposureConfig struct { - Allowlist []string `yaml:"allowlist,omitempty"` - Denylist []string `yaml:"denylist,omitempty"` - Agents []MCPAgentExposureConfig `yaml:"agents,omitempty"` -} - -type MCPAgentExposureConfig struct { - Agent string `yaml:"agent"` - Allowlist []string `yaml:"allowlist,omitempty"` -} - -type MCPServerConfig struct { - ID string `yaml:"id"` - Enabled bool `yaml:"enabled,omitempty"` - Source string `yaml:"source,omitempty"` - Version string `yaml:"version,omitempty"` - Stdio MCPStdioConfig `yaml:"stdio,omitempty"` - Env []MCPEnvVarConfig `yaml:"env,omitempty"` -} - -type MCPStdioConfig struct { - Command string `yaml:"command,omitempty"` - Args []string `yaml:"args,omitempty"` - Workdir string `yaml:"workdir,omitempty"` - StartTimeoutSec int `yaml:"start_timeout_sec,omitempty"` - CallTimeoutSec int `yaml:"call_timeout_sec,omitempty"` - RestartBackoffSec int `yaml:"restart_backoff_sec,omitempty"` -} - -type MCPEnvVarConfig struct { - Name string `yaml:"name"` - Value string `yaml:"value,omitempty"` - ValueEnv string `yaml:"value_env,omitempty"` -} - -// DefaultWebFetchSupportedContentTypes 返回 WebFetch 允许抓取的默认内容类型列表副本。 -func DefaultWebFetchSupportedContentTypes() []string { - return append([]string(nil), defaultWebFetchSupportedContentTypes...) -} - -// StaticDefaults 返回 config 层负责的静态默认值骨架,不包含 provider 装配和选择状态修复。 -func StaticDefaults() *Config { - return &Config{ - Workdir: DefaultWorkdir, - Shell: defaultShell(), - MaxLoops: DefaultMaxLoops, - ToolTimeoutSec: DefaultToolTimeoutSec, - Context: defaultContextConfig(), - Tools: ToolsConfig{ - WebFetch: defaultWebFetchConfig(), - MCP: defaultMCPConfig(), - }, - Memo: defaultMemoConfig(), - } -} - -// Default 兼容历史调用,返回配置层静态默认值骨架。 -func Default() *Config { - return StaticDefaults() -} - -func (c *Config) Clone() Config { - if c == nil { - return *StaticDefaults() - } - - clone := *c - clone.Providers = cloneProviders(c.Providers) - clone.Context = c.Context.Clone() - clone.Tools = c.Tools.Clone() - clone.Memo = c.Memo.Clone() - return clone -} - -// applyStaticDefaults 仅补齐静态配置默认值,不处理 provider 装配或当前选择状态修复。 -func (c *Config) applyStaticDefaults(defaults Config) { - if c == nil { - return - } - - if strings.TrimSpace(c.Workdir) == "" { - c.Workdir = defaults.Workdir - } - if strings.TrimSpace(c.Shell) == "" { - c.Shell = defaults.Shell - } - if c.MaxLoops <= 0 { - c.MaxLoops = defaults.MaxLoops - } - if c.ToolTimeoutSec <= 0 { - c.ToolTimeoutSec = defaults.ToolTimeoutSec - } - c.Context.ApplyDefaults(defaults.Context) - c.Tools.ApplyDefaults(defaults.Tools) - c.Memo.ApplyDefaults(defaults.Memo) - - c.Workdir = normalizeWorkdir(c.Workdir) -} - -// ValidateSnapshot 仅校验配置快照本身是否结构完整,不负责 selected_provider/current_model 的可运行性修正。 -func (c *Config) ValidateSnapshot() error { - if c == nil { - return errors.New("config: config is nil") - } - if len(c.Providers) == 0 { - return errors.New("config: providers is empty") - } - - seen := make(map[string]struct{}, len(c.Providers)) - seenEndpoints := make(map[string]string, len(c.Providers)) - for i, provider := range c.Providers { - if err := provider.Validate(); err != nil { - return fmt.Errorf("config: provider[%d]: %w", i, err) - } - - key := normalizeProviderName(provider.Name) - if _, exists := seen[key]; exists { - return fmt.Errorf("config: duplicate provider name %q", provider.Name) - } - seen[key] = struct{}{} - - identity, err := provider.Identity() - if err != nil { - return fmt.Errorf("config: provider[%d]: %w", i, err) - } - if existingName, exists := seenEndpoints[identity.Key()]; exists { - return fmt.Errorf( - "config: duplicate provider endpoint %q for providers %q and %q", - identity.String(), - existingName, - provider.Name, - ) - } - seenEndpoints[identity.Key()] = provider.Name - } - - if strings.TrimSpace(c.Workdir) == "" { - return errors.New("config: workdir is empty") - } - if !filepath.IsAbs(c.Workdir) { - return fmt.Errorf("config: workdir must be absolute, got %q", c.Workdir) - } - if err := c.Tools.Validate(); err != nil { - return fmt.Errorf("config: tools: %w", err) - } - if err := c.Context.Validate(); err != nil { - return fmt.Errorf("config: context: %w", err) - } - if err := c.Memo.Validate(); err != nil { - return fmt.Errorf("config: memo: %w", err) - } - - return nil -} - -func (c *Config) ProviderByName(name string) (ProviderConfig, error) { - if c == nil { - return ProviderConfig{}, errors.New("config: config is nil") - } - - target := normalizeProviderName(name) - for _, provider := range c.Providers { - if normalizeProviderName(provider.Name) == target { - return provider, nil - } - } - - return ProviderConfig{}, fmt.Errorf("config: provider %q not found", name) -} - -// ResolveSelectedProvider 解析当前配置中选中的 provider,并补全运行时所需的密钥信息。 -func ResolveSelectedProvider(cfg Config) (ResolvedProviderConfig, error) { - providerName := strings.TrimSpace(cfg.SelectedProvider) - if providerName == "" { - return ResolvedProviderConfig{}, errors.New("config: selected provider is empty") - } - - providerCfg, err := cfg.ProviderByName(providerName) - if err != nil { - return ResolvedProviderConfig{}, err - } - return providerCfg.Resolve() -} - -func (p ProviderConfig) Validate() error { - if strings.TrimSpace(p.Name) == "" { - return errors.New("provider name is empty") - } - if normalizeProviderDriver(p.Driver) == "" { - return fmt.Errorf("provider %q driver is empty", p.Name) - } - if strings.TrimSpace(p.BaseURL) == "" { - return fmt.Errorf("provider %q base_url is empty", p.Name) - } - if p.Source == ProviderSourceCustom && strings.TrimSpace(p.Model) != "" { - return fmt.Errorf("provider %q custom providers must not define model", p.Name) - } - if p.Source != ProviderSourceCustom && strings.TrimSpace(p.Model) == "" { - return fmt.Errorf("provider %q model is empty", p.Name) - } - if strings.TrimSpace(p.APIKeyEnv) == "" { - return fmt.Errorf("provider %q api_key_env is empty", p.Name) - } - if _, err := p.Identity(); err != nil { - return fmt.Errorf("provider %q: %w", p.Name, err) - } - return nil -} - -func (p ProviderConfig) Identity() (provider.ProviderIdentity, error) { - return providerIdentityFromConfig(p) -} - -func (p ProviderConfig) ResolveAPIKey() (string, error) { - envName := strings.TrimSpace(p.APIKeyEnv) - if envName == "" { - return "", fmt.Errorf("config: provider %q api_key_env is empty", p.Name) - } - - value := strings.TrimSpace(os.Getenv(envName)) - if value == "" { - return "", fmt.Errorf("config: environment variable %s is empty", envName) - } - - return value, nil -} - -func (p ProviderConfig) Resolve() (ResolvedProviderConfig, error) { - apiKey, err := p.ResolveAPIKey() - if err != nil { - return ResolvedProviderConfig{}, err - } - - return ResolvedProviderConfig{ - ProviderConfig: p, - APIKey: apiKey, - }, nil -} - -func normalizeWorkdir(workdir string) string { - workdir = strings.TrimSpace(workdir) - if workdir == "" { - return "" - } - - if workdir == "." { - if wd, err := os.Getwd(); err == nil { - return wd - } - return workdir - } - - if filepath.IsAbs(workdir) { - return filepath.Clean(workdir) - } - - if wd, err := os.Getwd(); err == nil { - return filepath.Clean(filepath.Join(wd, workdir)) - } - - return filepath.Clean(workdir) -} - -func defaultShell() string { - if goruntime.GOOS == "windows" { - return "powershell" - } - return "bash" -} - -func defaultWebFetchConfig() WebFetchConfig { - return WebFetchConfig{ - MaxResponseBytes: DefaultWebFetchMaxResponseBytes, - SupportedContentTypes: DefaultWebFetchSupportedContentTypes(), - } -} - -// defaultMCPConfig 返回 MCP 工具接入配置的默认值(默认无 server)。 -func defaultMCPConfig() MCPConfig { - return MCPConfig{ - Servers: nil, - } -} - -// defaultContextConfig 返回上下文压缩相关配置的默认值。 -func defaultContextConfig() ContextConfig { - return ContextConfig{ - Compact: defaultCompactConfig(), - AutoCompact: defaultAutoCompactConfig(), - } -} - -func defaultAutoCompactConfig() AutoCompactConfig { - return AutoCompactConfig{ - InputTokenThreshold: DefaultAutoCompactInputTokenThreshold, - } -} - -// defaultMemoConfig 返回跨会话记忆的默认配置。 -func defaultMemoConfig() MemoConfig { - return MemoConfig{ - Enabled: true, - AutoExtract: true, - MaxIndexLines: DefaultMemoMaxIndexLines, - } -} - -// defaultCompactConfig 返回手动 compact 策略的默认配置。 -func defaultCompactConfig() CompactConfig { - return CompactConfig{ - ManualStrategy: CompactManualStrategyKeepRecent, - ManualKeepRecentMessages: DefaultCompactManualKeepRecentMessages, - MaxSummaryChars: DefaultCompactMaxSummaryChars, - } -} - -func (c ToolsConfig) Clone() ToolsConfig { - return ToolsConfig{ - WebFetch: c.WebFetch.Clone(), - MCP: c.MCP.Clone(), - } -} - -// Clone 返回上下文配置的独立副本,避免后续修改污染原值。 -func (c ContextConfig) Clone() ContextConfig { - return ContextConfig{ - Compact: c.Compact.Clone(), - AutoCompact: c.AutoCompact.Clone(), - } -} - -func (c *ToolsConfig) ApplyDefaults(defaults ToolsConfig) { - if c == nil { - return - } - - c.WebFetch.ApplyDefaults(defaults.WebFetch) - c.MCP.ApplyDefaults(defaults.MCP) -} - -// ApplyDefaults 为上下文配置补齐缺省的 compact 参数。 -func (c *ContextConfig) ApplyDefaults(defaults ContextConfig) { - if c == nil { - return - } - - c.Compact.ApplyDefaults(defaults.Compact) - c.AutoCompact.ApplyDefaults(defaults.AutoCompact) -} - -func (c ToolsConfig) Validate() error { - if err := c.WebFetch.Validate(); err != nil { - return fmt.Errorf("webfetch: %w", err) - } - if err := c.MCP.Validate(); err != nil { - return fmt.Errorf("mcp: %w", err) - } - return nil -} - -// Clone 返回 MCP 配置的独立副本,避免引用共享造成并发污染。 -func (c MCPConfig) Clone() MCPConfig { - cloned := MCPConfig{ - Exposure: c.Exposure.Clone(), - } - if len(c.Servers) == 0 { - return cloned - } - cloned.Servers = make([]MCPServerConfig, 0, len(c.Servers)) - for _, server := range c.Servers { - cloned.Servers = append(cloned.Servers, server.Clone()) - } - return cloned -} - -// Clone 返回单个 MCP server 配置的独立副本。 -func (c MCPServerConfig) Clone() MCPServerConfig { - cloned := c - cloned.Stdio.Args = append([]string(nil), c.Stdio.Args...) - if len(c.Env) > 0 { - cloned.Env = make([]MCPEnvVarConfig, 0, len(c.Env)) - cloned.Env = append(cloned.Env, c.Env...) - } else { - cloned.Env = nil - } - return cloned -} - -// ApplyDefaults 为 MCP 配置补齐缺省字段,保证运行时行为可预测。 -func (c *MCPConfig) ApplyDefaults(defaults MCPConfig) { - if c == nil { - return - } - if len(c.Servers) == 0 { - c.Servers = defaults.Clone().Servers - } - c.Exposure.ApplyDefaults(defaults.Exposure) - for index := range c.Servers { - c.Servers[index].ApplyDefaults() - } -} - -// Validate 校验 MCP server 列表与字段合法性,防止启动后失败。 -func (c MCPConfig) Validate() error { - if err := c.Exposure.Validate(); err != nil { - return fmt.Errorf("exposure: %w", err) - } - if len(c.Servers) == 0 { - return nil - } - seen := make(map[string]struct{}, len(c.Servers)) - for index, server := range c.Servers { - normalizedID := strings.ToLower(strings.TrimSpace(server.ID)) - if normalizedID == "" { - return fmt.Errorf("servers[%d].id is empty", index) - } - if _, exists := seen[normalizedID]; exists { - return fmt.Errorf("duplicate servers[%d].id %q", index, server.ID) - } - seen[normalizedID] = struct{}{} - - source := strings.ToLower(strings.TrimSpace(server.Source)) - if source == "" { - source = "stdio" - } - if source != "stdio" { - return fmt.Errorf("servers[%d].source %q is not supported", index, server.Source) - } - if !server.Enabled { - continue - } - - if strings.TrimSpace(server.Stdio.Command) == "" { - return fmt.Errorf("servers[%d].stdio.command is empty", index) - } - for envIndex, env := range server.Env { - if strings.TrimSpace(env.Name) == "" { - return fmt.Errorf("servers[%d].env[%d].name is empty", index, envIndex) - } - hasValue := strings.TrimSpace(env.Value) != "" - hasValueEnv := strings.TrimSpace(env.ValueEnv) != "" - if hasValue == hasValueEnv { - return fmt.Errorf("servers[%d].env[%d] must set exactly one of value/value_env", index, envIndex) - } - } - } - return nil -} - -// Clone 返回 MCP 工具暴露过滤配置的独立副本。 -func (c MCPExposureConfig) Clone() MCPExposureConfig { - cloned := MCPExposureConfig{ - Allowlist: append([]string(nil), c.Allowlist...), - Denylist: append([]string(nil), c.Denylist...), - } - if len(c.Agents) > 0 { - cloned.Agents = make([]MCPAgentExposureConfig, 0, len(c.Agents)) - for _, agent := range c.Agents { - cloned.Agents = append(cloned.Agents, agent.Clone()) - } - } - return cloned -} - -// Clone 返回单条 agent 暴露规则的独立副本。 -func (c MCPAgentExposureConfig) Clone() MCPAgentExposureConfig { - cloned := c - cloned.Allowlist = append([]string(nil), c.Allowlist...) - return cloned -} - -// ApplyDefaults 规范化 MCP 工具暴露过滤配置。 -func (c *MCPExposureConfig) ApplyDefaults(defaults MCPExposureConfig) { - if c == nil { - return - } - c.Allowlist = normalizePatternList(c.Allowlist) - c.Denylist = normalizePatternList(c.Denylist) - for index := range c.Agents { - c.Agents[index].ApplyDefaults() - } -} - -// ApplyDefaults 规范化单条 agent 暴露规则。 -func (c *MCPAgentExposureConfig) ApplyDefaults() { - if c == nil { - return - } - c.Agent = strings.TrimSpace(c.Agent) - c.Allowlist = normalizePatternList(c.Allowlist) -} - -// Validate 校验 MCP 暴露过滤配置合法性。 -func (c MCPExposureConfig) Validate() error { - seenAgents := make(map[string]struct{}, len(c.Agents)) - for index, agent := range c.Agents { - normalizedAgent := strings.ToLower(strings.TrimSpace(agent.Agent)) - if normalizedAgent == "" { - return fmt.Errorf("agents[%d].agent is empty", index) - } - if _, exists := seenAgents[normalizedAgent]; exists { - return fmt.Errorf("duplicate agents[%d].agent %q", index, agent.Agent) - } - seenAgents[normalizedAgent] = struct{}{} - for allowIndex, pattern := range agent.Allowlist { - if strings.TrimSpace(pattern) == "" { - return fmt.Errorf("agents[%d].allowlist[%d] is empty", index, allowIndex) - } - } - } - for index, pattern := range c.Allowlist { - if strings.TrimSpace(pattern) == "" { - return fmt.Errorf("allowlist[%d] is empty", index) - } - } - for index, pattern := range c.Denylist { - if strings.TrimSpace(pattern) == "" { - return fmt.Errorf("denylist[%d] is empty", index) - } - } - return nil -} - -// Validate 校验上下文压缩配置是否合法。 -func (c ContextConfig) Validate() error { - if err := c.Compact.Validate(); err != nil { - return fmt.Errorf("compact: %w", err) - } - if err := c.AutoCompact.Validate(); err != nil { - return fmt.Errorf("auto_compact: %w", err) - } - return nil -} - -func (c WebFetchConfig) Clone() WebFetchConfig { - clone := c - clone.SupportedContentTypes = append([]string(nil), c.SupportedContentTypes...) - return clone -} - -// Clone 返回 compact 配置的值副本。 -func (c CompactConfig) Clone() CompactConfig { - return c -} - -// Clone 返回 auto_compact 配置的值副本。 -func (c AutoCompactConfig) Clone() AutoCompactConfig { - return c -} - -// Clone 返回 memo 配置的值副本。 -func (c MemoConfig) Clone() MemoConfig { - return c -} - -// ApplyDefaults 为 auto_compact 配置填充缺省阈值。 -func (c *AutoCompactConfig) ApplyDefaults(defaults AutoCompactConfig) { - if c == nil { - return - } - if c.InputTokenThreshold <= 0 { - c.InputTokenThreshold = defaults.InputTokenThreshold - } -} - -// ApplyDefaults 为 memo 配置补齐缺省参数。 -func (c *MemoConfig) ApplyDefaults(defaults MemoConfig) { - if c == nil { - return - } - if c.MaxIndexLines <= 0 { - c.MaxIndexLines = defaults.MaxIndexLines - } -} - -// Validate 校验 memo 配置是否合法。 -func (c MemoConfig) Validate() error { - if c.MaxIndexLines < 0 { - return errors.New("max_index_lines must be non-negative") - } - return nil -} - -// Validate 校验 auto_compact 配置是否合法。 -func (c AutoCompactConfig) Validate() error { - if c.Enabled && c.InputTokenThreshold <= 0 { - return errors.New("input_token_threshold must be greater than 0 when enabled") - } - return nil -} - -func (c *WebFetchConfig) ApplyDefaults(defaults WebFetchConfig) { - if c == nil { - return - } - - if c.MaxResponseBytes <= 0 { - c.MaxResponseBytes = defaults.MaxResponseBytes - } - c.SupportedContentTypes = normalizeContentTypes(c.SupportedContentTypes, defaults.SupportedContentTypes) -} - -// ApplyDefaults 为 MCP server stdio 配置补齐默认值。 -func (c *MCPServerConfig) ApplyDefaults() { - if c == nil { - return - } - c.Source = strings.ToLower(strings.TrimSpace(c.Source)) - if c.Source == "" { - c.Source = "stdio" - } -} - -// normalizePatternList 规范化暴露过滤模式列表并剔除空项。 -func normalizePatternList(values []string) []string { - if len(values) == 0 { - return nil - } - result := make([]string, 0, len(values)) - for _, value := range values { - trimmed := strings.ToLower(strings.TrimSpace(value)) - if trimmed == "" { - continue - } - result = append(result, trimmed) - } - if len(result) == 0 { - return nil - } - return result -} - -// ApplyDefaults 为 compact 配置填充缺省策略和阈值。 -func (c *CompactConfig) ApplyDefaults(defaults CompactConfig) { - if c == nil { - return - } - - if strings.TrimSpace(c.ManualStrategy) == "" { - c.ManualStrategy = defaults.ManualStrategy - } - if c.ManualKeepRecentMessages <= 0 { - c.ManualKeepRecentMessages = defaults.ManualKeepRecentMessages - } - if c.MaxSummaryChars <= 0 { - c.MaxSummaryChars = defaults.MaxSummaryChars - } -} - -func (c WebFetchConfig) Validate() error { - if c.MaxResponseBytes <= 0 { - return errors.New("max_response_bytes must be greater than 0") - } - if len(c.SupportedContentTypes) == 0 { - return errors.New("supported_content_types is empty") - } - - for i, contentType := range c.SupportedContentTypes { - if normalizeContentType(contentType) == "" { - return fmt.Errorf("supported_content_types[%d] is empty", i) - } - } - return nil -} - -// Validate 校验 compact 配置中的策略和阈值是否可用。 -func (c CompactConfig) Validate() error { - if c.ManualKeepRecentMessages <= 0 { - return errors.New("manual_keep_recent_messages must be greater than 0") - } - if c.MaxSummaryChars <= 0 { - return errors.New("max_summary_chars must be greater than 0") - } - - switch strings.ToLower(strings.TrimSpace(c.ManualStrategy)) { - case CompactManualStrategyKeepRecent, CompactManualStrategyFullReplace: - return nil - default: - return fmt.Errorf("manual_strategy %q is not supported", c.ManualStrategy) - } -} - -func normalizeContentTypes(values []string, defaults []string) []string { - source := values - if len(source) == 0 { - source = defaults - } - - normalized := make([]string, 0, len(source)) - seen := make(map[string]struct{}, len(source)) - for _, value := range source { - contentType := normalizeContentType(value) - if contentType == "" { - continue - } - if _, exists := seen[contentType]; exists { - continue - } - seen[contentType] = struct{}{} - normalized = append(normalized, contentType) - } - return normalized -} - -func normalizeContentType(value string) string { - trimmed := strings.ToLower(strings.TrimSpace(value)) - if trimmed == "" { - return "" - } - - mediaType, _, err := mime.ParseMediaType(trimmed) - if err == nil { - return mediaType - } - - if index := strings.Index(trimmed, ";"); index >= 0 { - return strings.TrimSpace(trimmed[:index]) - } - return trimmed -} - -func cloneProviders(providers []ProviderConfig) []ProviderConfig { - if len(providers) == 0 { - return nil - } - - cloned := make([]ProviderConfig, 0, len(providers)) - for _, provider := range providers { - cloned = append(cloned, cloneProviderConfig(provider)) - } - return cloned -} - -// cloneProviderConfig 返回 provider 配置的深拷贝,避免模型元数据等切片在不同快照间共享。 -func cloneProviderConfig(provider ProviderConfig) ProviderConfig { - cloned := provider - cloned.Models = providertypes.CloneModelDescriptors(provider.Models) - return cloned -} - -func containsProviderName(providers []ProviderConfig, name string) bool { - target := normalizeProviderName(name) - if target == "" { - return false - } - for _, provider := range providers { - if normalizeProviderName(provider.Name) == target { - return true - } - } - return false -} diff --git a/internal/config/model_test.go b/internal/config/model_test.go deleted file mode 100644 index 14956bdc..00000000 --- a/internal/config/model_test.go +++ /dev/null @@ -1,1205 +0,0 @@ -package config - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - providerpkg "neo-code/internal/provider" - providertypes "neo-code/internal/provider/types" -) - -// ---- 辅助函数与默认值测试 ---- - -func TestNormalizeWorkdirEdgeCases(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - check func(t *testing.T, value string) - }{ - { - name: "empty string returns empty", - input: "", - check: func(t *testing.T, value string) { - if value != "" { - t.Fatalf("expected empty, got %q", value) - } - }, - }, - { - name: "whitespace only returns empty", - input: " ", - check: func(t *testing.T, value string) { - if value != "" { - t.Fatalf("expected empty for whitespace-only input, got %q", value) - } - }, - }, - { - name: "dot resolves to cwd", - input: ".", - check: func(t *testing.T, value string) { - if !filepath.IsAbs(value) { - t.Fatalf("expected absolute path for dot, got %q", value) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := normalizeWorkdir(tt.input) - tt.check(t, got) - }) - } -} - -func TestContainsProviderName(t *testing.T) { - t.Parallel() - - providers := []ProviderConfig{ - {Name: "OpenAI"}, - {Name: "Gemini"}, - } - if !containsProviderName(providers, "openai") { - t.Fatal("expected openai to be found (case insensitive)") - } - if !containsProviderName(providers, "OPENAI") { - t.Fatal("expected OPENAI to be found") - } - if containsProviderName(providers, "Anthropic") { - t.Fatal("expected Anthropic to not be found") - } - if containsProviderName(nil, "openai") { - t.Fatal("expected nil providers to return false") - } - if containsProviderName(providers, "") { - t.Fatal("expected empty name to return false") - } - if containsProviderName(providers, " ") { - t.Fatal("expected whitespace name to return false") - } -} - -func TestDefaultWebFetchSupportedContentTypesReturnsCopy(t *testing.T) { - t.Parallel() - - types1 := DefaultWebFetchSupportedContentTypes() - types2 := DefaultWebFetchSupportedContentTypes() - if &types1[0] == &types2[0] { - t.Fatal("expected independent slices") - } - if len(types1) == 0 { - t.Fatal("expected non-empty default content types") - } -} - -func TestStaticReturnsCompleteSkeleton(t *testing.T) { - t.Parallel() - - cfg := StaticDefaults() - if cfg == nil { - t.Fatal("expected non-nil static defaults") - } - if cfg.Workdir == "" { - t.Fatal("expected workdir to be set") - } - if cfg.Shell == "" { - t.Fatal("expected shell to be set") - } - if cfg.MaxLoops == 0 { - t.Fatal("expected max_loops to be set") - } - if cfg.ToolTimeoutSec == 0 { - t.Fatal("expected tool_timeout_sec to be set") - } - if cfg.Tools.WebFetch.MaxResponseBytes == 0 { - t.Fatal("expected webfetch max_response_bytes to be set") - } -} - -// ---- Clone 独立性测试 ---- - -func TestWebFetchConfigCloneIndependence(t *testing.T) { - t.Parallel() - - original := WebFetchConfig{ - MaxResponseBytes: 4096, - SupportedContentTypes: []string{"text/html", "application/json"}, - } - cloned := original.Clone() - - cloned.MaxResponseBytes = 1024 - cloned.SupportedContentTypes[0] = "text/plain" - - if original.MaxResponseBytes == cloned.MaxResponseBytes { - t.Fatal("expected MaxResponseBytes clone to be independent") - } - if original.SupportedContentTypes[0] == cloned.SupportedContentTypes[0] { - t.Fatal("expected SupportedContentTypes clone to be independent") - } -} - -func TestContextConfigCloneIndependence(t *testing.T) { - t.Parallel() - - original := ContextConfig{ - Compact: CompactConfig{ - ManualStrategy: CompactManualStrategyKeepRecent, - ManualKeepRecentMessages: 10, - MaxSummaryChars: 1200, - }, - AutoCompact: AutoCompactConfig{ - Enabled: true, - InputTokenThreshold: 50000, - }, - } - cloned := original.Clone() - - cloned.Compact.ManualStrategy = CompactManualStrategyFullReplace - cloned.Compact.ManualKeepRecentMessages = 5 - cloned.AutoCompact.Enabled = false - cloned.AutoCompact.InputTokenThreshold = 100000 - - if original.Compact.ManualStrategy == cloned.Compact.ManualStrategy { - t.Fatal("expected Compact Clone to be independent") - } - if original.Compact.ManualKeepRecentMessages == cloned.Compact.ManualKeepRecentMessages { - t.Fatal("expected ManualKeepRecentMessages Clone to be independent") - } - if original.AutoCompact.Enabled == cloned.AutoCompact.Enabled { - t.Fatal("expected AutoCompact Enabled clone to be independent") - } - if original.AutoCompact.InputTokenThreshold == cloned.AutoCompact.InputTokenThreshold { - t.Fatal("expected AutoCompact InputTokenThreshold clone to be independent") - } -} - -func TestCompactConfigCloneValueSemantics(t *testing.T) { - t.Parallel() - - original := CompactConfig{ - ManualStrategy: CompactManualStrategyFullReplace, - ManualKeepRecentMessages: 5, - MaxSummaryChars: 800, - MicroCompactDisabled: true, - } - cloned := original.Clone() - if original != cloned { - t.Fatalf("expected equal configs, got %+v vs %+v", original, cloned) - } -} - -func TestAutoCompactConfigCloneValueSemantics(t *testing.T) { - t.Parallel() - - original := AutoCompactConfig{Enabled: true, InputTokenThreshold: 75000} - cloned := original.Clone() - if original != cloned { - t.Fatalf("expected equal configs, got %+v vs %+v", original, cloned) - } -} - -// ---- nil 接收者 ApplyDefaults 测试 ---- - -func TestApplyDefaultsNilReceivers(t *testing.T) { - t.Parallel() - - var toolsCfg *ToolsConfig - toolsCfg.ApplyDefaults(ToolsConfig{}) - // 不应 panic - - var ctxCfg *ContextConfig - ctxCfg.ApplyDefaults(ContextConfig{}) - // 不应 panic - - var mcpCfg *MCPConfig - mcpCfg.ApplyDefaults(MCPConfig{}) - // 不应 panic - - var wfCfg *WebFetchConfig - wfCfg.ApplyDefaults(WebFetchConfig{}) - // 不应 panic - - var ccfg *CompactConfig - ccfg.ApplyDefaults(CompactConfig{}) - // 不应 panic - - var serverCfg *MCPServerConfig - serverCfg.ApplyDefaults() - // 不应 panic -} - -func TestApplyStaticDefaultsNilReceiver(t *testing.T) { - t.Parallel() - - var cfg *Config - cfg.applyStaticDefaults(*StaticDefaults()) - // 不应 panic,cfg 应仍为 nil - if cfg != nil { - t.Fatal("expected nil config to remain nil") - } -} - -// ---- 校验边界测试 ---- - -func TestValidateSnapshotRejectsEmptyWorkdir(t *testing.T) { - t.Parallel() - - cfg := Config{ - Providers: []ProviderConfig{testDefaultProviderConfig()}, - SelectedProvider: testProviderName, - CurrentModel: testModel, - Workdir: "", - Shell: "powershell", - } - err := cfg.ValidateSnapshot() - if err == nil || !strings.Contains(err.Error(), "workdir is empty") { - t.Fatalf("expected empty workdir error, got %v", err) - } -} - -func TestValidateSnapshotPropagatesCompactError(t *testing.T) { - t.Parallel() - - workdir := filepath.Clean(t.TempDir()) - cfg := Config{ - Providers: []ProviderConfig{testDefaultProviderConfig()}, - SelectedProvider: testProviderName, - CurrentModel: testModel, - Workdir: workdir, - Shell: "powershell", - Tools: ToolsConfig{ - WebFetch: WebFetchConfig{ - MaxResponseBytes: DefaultWebFetchMaxResponseBytes, - SupportedContentTypes: []string{"text/html"}, - }, - }, - Context: ContextConfig{ - Compact: CompactConfig{ - ManualStrategy: "invalid_strategy", - ManualKeepRecentMessages: 10, - MaxSummaryChars: 1200, - }, - }, - } - err := cfg.ValidateSnapshot() - if err == nil || !strings.Contains(err.Error(), "compact") { - t.Fatalf("expected compact error, got %v", err) - } -} - -func TestResolveSelectedProviderEmptyString(t *testing.T) { - t.Parallel() - - cfg := Config{ - SelectedProvider: "", - Providers: []ProviderConfig{testDefaultProviderConfig()}, - } - _, err := ResolveSelectedProvider(cfg) - if err == nil || !strings.Contains(err.Error(), "selected provider is empty") { - t.Fatalf("expected empty selected provider error, got %v", err) - } -} - -func TestResolveSelectedProviderNotFound(t *testing.T) { - t.Parallel() - - cfg := Config{ - SelectedProvider: "nonexistent-provider", - Providers: []ProviderConfig{testDefaultProviderConfig()}, - } - _, err := ResolveSelectedProvider(cfg) - if err == nil || !strings.Contains(err.Error(), "not found") { - t.Fatalf("expected not found error, got %v", err) - } -} - -func TestProviderConfigIdentity(t *testing.T) { - t.Parallel() - - cfg := ProviderConfig{ - Name: "test-openai", - Driver: "openaicompat", - BaseURL: "https://api.openai.com/v1", - Model: "gpt-4o", - APIKeyEnv: "TEST_KEY", - APIStyle: "chat_completions", - } - - identity, err := cfg.Identity() - if err != nil { - t.Fatalf("Identity() error = %v", err) - } - if identity.Driver != "openaicompat" { - t.Fatalf("expected driver openaicompat, got %q", identity.Driver) - } - if identity.BaseURL != "https://api.openai.com/v1" { - t.Fatalf("expected base URL, got %q", identity.BaseURL) - } - if identity.APIStyle != "chat_completions" { - t.Fatalf("expected api_style chat_completions, got %q", identity.APIStyle) - } -} - -func TestProviderConfigResolveAPIKeyEmptyEnvName(t *testing.T) { - t.Parallel() - - cfg := ProviderConfig{Name: "test", APIKeyEnv: ""} - _, err := cfg.ResolveAPIKey() - if err == nil || !strings.Contains(err.Error(), "api_key_env is empty") { - t.Fatalf("expected empty api_key_env error, got %v", err) - } -} - -// ---- 内容类型规范化测试 ---- - -func TestNormalizeContentType(t *testing.T) { - t.Parallel() - - tests := []struct { - input string - expected string - }{ - {input: "text/html", expected: "text/html"}, - {input: " Text/HTML ", expected: "text/html"}, - {input: "text/html; charset=utf-8", expected: "text/html"}, - {input: "APPLICATION/JSON", expected: "application/json"}, - {input: " ", expected: ""}, - {input: "", expected: ""}, - {input: "application/x-www-form-urlencoded; charset=utf-8", expected: "application/x-www-form-urlencoded"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - t.Parallel() - got := normalizeContentType(tt.input) - if got != tt.expected { - t.Fatalf("normalizeContentType(%q) = %q, want %q", tt.input, got, tt.expected) - } - }) - } -} - -func TestNormalizeContentTypesDeduplicatesAndCleans(t *testing.T) { - t.Parallel() - - defaults := []string{"text/html", "text/plain"} - result := normalizeContentTypes([]string{"TEXT/HTML", " text/plain ", "text/html", "", " "}, defaults) - if len(result) != 2 { - t.Fatalf("expected 2 unique content types, got %d: %+v", len(result), result) - } -} - -func TestNormalizeContentTypesFallsBackToDefaultsWhenEmpty(t *testing.T) { - t.Parallel() - - defaults := []string{"text/html", "text/plain"} - // 当所有输入值都为空或纯空白时,它们全部被过滤掉,结果为空切片 - // (normalizeContentTypes 只在 source 长度为 0 时才回退到 defaults,而这里 source 长度为 2) - result := normalizeContentTypes([]string{"", " "}, defaults) - if len(result) != 0 { - t.Fatalf("expected 0 types when all inputs are empty, got %d: %+v", len(result), result) - } -} - -// ---- Manager 边界测试 ---- - -func TestManagerUpdateNilMutateFunc(t *testing.T) { - tempDir := t.TempDir() - manager := NewManager(NewLoader(tempDir, testDefaultConfig())) - if _, err := manager.Load(context.Background()); err != nil { - t.Fatalf("Load() error = %v", err) - } - - err := manager.Update(context.Background(), nil) - if err == nil || !strings.Contains(err.Error(), "mutate func is nil") { - t.Fatalf("expected nil mutate error, got %v", err) - } -} - -func TestManagerUpdateValidationFailurePreservesCurrentState(t *testing.T) { - tempDir := t.TempDir() - manager := NewManager(NewLoader(tempDir, testDefaultConfig())) - if _, err := manager.Load(context.Background()); err != nil { - t.Fatalf("Load() error = %v", err) - } - - before := manager.Get() - // 使用无效的 compact strategy 触发验证失败(applyStaticDefaults 不会修复这个字段) - err := manager.Update(context.Background(), func(cfg *Config) error { - cfg.Context.Compact.ManualStrategy = "totally_invalid_strategy" - return nil - }) - if err == nil { - t.Fatal("expected validation error") - } - - after := manager.Get() - if before.SelectedProvider != after.SelectedProvider { - t.Fatalf("expected selected provider to be preserved after failed update") - } -} - -// ---- Loader 边界测试 ---- - -func TestParseConfigWithEmptyData(t *testing.T) { - t.Parallel() - - cfg, err := parseConfigWithContextDefaults([]byte{}, StaticDefaults().Context) - if err != nil { - t.Fatalf("parseConfigWithContextDefaults(empty) error = %v", err) - } - if cfg == nil { - t.Fatal("expected non-nil config for empty data") - } -} - -func TestParseConfigWithWhitespaceOnlyData(t *testing.T) { - t.Parallel() - - cfg, err := parseConfigWithContextDefaults([]byte(" \n\t "), StaticDefaults().Context) - if err != nil { - t.Fatalf("parseConfigWithContextDefaults(whitespace) error = %v", err) - } - if cfg == nil { - t.Fatal("expected non-nil config for whitespace-only data") - } -} - -func TestMarshalPersistedConfigEndsWithNewline(t *testing.T) { - t.Parallel() - - snapshot := testDefaultConfig().Clone() - data, err := marshalPersistedConfig(snapshot) - if err != nil { - t.Fatalf("marshalPersistedConfig() error = %v", err) - } - if len(data) == 0 { - t.Fatal("expected non-empty marshaled data") - } - if data[len(data)-1] != '\n' { - t.Fatal("expected marshaled data to end with newline") - } -} - -func TestAssembleProvidersAcceptsEmptyNameProvider(t *testing.T) { - t.Parallel() - - custom := []ProviderConfig{{ - Name: "", - Driver: "custom-driver", - BaseURL: "https://example.com/v1", - APIKeyEnv: "CUSTOM_KEY", - Source: ProviderSourceCustom, - }} - - assembled, err := assembleProviders(testDefaultConfig().Providers, custom) - if err != nil { - t.Fatalf("assembleProviders() with empty name error = %v", err) - } - if len(assembled) < 2 { - t.Fatalf("expected at least 2 providers, got %d", len(assembled)) - } -} - -func TestAssembleProvidersDuplicateBetweenBuiltinAndCustom(t *testing.T) { - t.Parallel() - - custom := []ProviderConfig{{ - Name: testProviderName, // 与内置同名 - Driver: "custom-driver", - BaseURL: "https://example.com/v1", - APIKeyEnv: "CUSTOM_KEY", - Source: ProviderSourceCustom, - }} - - _, err := assembleProviders(testDefaultConfig().Providers, custom) - if err == nil || !strings.Contains(err.Error(), "duplicate provider name") { - t.Fatalf("expected duplicate error between builtin/custom, got %v", err) - } -} - -// ---- runtime 转换测试 ---- - -func TestToRuntimeConfigMapsAllFields(t *testing.T) { - t.Parallel() - - resolved := ResolvedProviderConfig{ - ProviderConfig: ProviderConfig{ - Name: "test-provider", - Driver: "gemini", - BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", - Model: "gemini-2.5-flash", - APIKeyEnv: "TEST_ENV_KEY", - APIStyle: "responses", - DeploymentMode: "vertex", - APIVersion: "v1beta", - }, - APIKey: "resolved-secret-key", - } - - got := resolved.ToRuntimeConfig() - if got.Name != "test-provider" { - t.Fatalf("expected Name=test-provider, got %q", got.Name) - } - if got.Driver != "gemini" { - t.Fatalf("expected Driver=gemini, got %q", got.Driver) - } - if got.DefaultModel != "gemini-2.5-flash" { - t.Fatalf("expected DefaultModel=gemini-2.5-flash, got %q", got.DefaultModel) - } - if got.APIKey != "resolved-secret-key" { - t.Fatalf("expected APIKey=resolved-secret-key, got %q", got.APIKey) - } - if got.DeploymentMode != "vertex" { - t.Fatalf("expected DeploymentMode=vertex, got %q", got.DeploymentMode) - } - if got.APIVersion != "v1beta" { - t.Fatalf("expected APIVersion=v1beta, got %q", got.APIVersion) - } -} - -// ---- MCP 配置测试 ---- - -func TestMCPServerConfigApplyDefaultsNormalizesSource(t *testing.T) { - t.Parallel() - - cfg := MCPServerConfig{ - ID: "test-server", - Enabled: true, - Source: " STDIO ", - Stdio: MCPStdioConfig{Command: "cmd"}, - } - cfg.ApplyDefaults() - if cfg.Source != "stdio" { - t.Fatalf("expected normalized source 'stdio', got %q", cfg.Source) - } -} - -func TestMCPServerConfigApplyDefaultsSetsStdioWhenEmpty(t *testing.T) { - t.Parallel() - - cfg := MCPServerConfig{ - ID: "test-server", - Enabled: true, - Source: "", - Stdio: MCPStdioConfig{Command: "cmd"}, - } - cfg.ApplyDefaults() - if cfg.Source != "stdio" { - t.Fatalf("expected default source 'stdio', got %q", cfg.Source) - } -} - -func TestMCPConfigValidateSkipsDisabledServerWithoutCommand(t *testing.T) { - t.Parallel() - - cfg := MCPConfig{ - Servers: []MCPServerConfig{ - {ID: "disabled-no-cmd", Enabled: false, Source: "stdio"}, - }, - } - if err := cfg.Validate(); err != nil { - t.Fatalf("expected disabled server without command to pass validation, got %v", err) - } -} - -func TestMCPConfigValidateRejectsServerWithEmptyEnvBothValues(t *testing.T) { - t.Parallel() - - cfg := MCPConfig{ - Servers: []MCPServerConfig{ - { - ID: "test", - Enabled: true, - Source: "stdio", - Stdio: MCPStdioConfig{Command: "cmd"}, - Env: []MCPEnvVarConfig{ - {Name: "VAR", Value: "", ValueEnv: ""}, - {Name: "VAR2", Value: "a", ValueEnv: "b"}, - }, - }, - }, - } - err := cfg.Validate() - // 至少应该有一个错误(两个 env 都有问题) - if err == nil { - t.Fatal("expected validation error for invalid env bindings") - } -} - -func TestMCPConfigValidateRejectsServerWithMissingEnvName(t *testing.T) { - t.Parallel() - - cfg := MCPConfig{ - Servers: []MCPServerConfig{ - { - ID: "test", - Enabled: true, - Source: "stdio", - Stdio: MCPStdioConfig{Command: "cmd"}, - Env: []MCPEnvVarConfig{ - {Name: " ", Value: "a"}, - }, - }, - }, - } - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "name is empty") { - t.Fatalf("expected empty env name error, got %v", err) - } -} - -// ---- WebFetch 配置测试 ---- - -func TestWebFetchConfigValidateRejectsEmptyContentType(t *testing.T) { - t.Parallel() - - cfg := WebFetchConfig{ - MaxResponseBytes: 1024, - SupportedContentTypes: []string{"text/html", "", "application/json"}, - } - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "[1] is empty") { - t.Fatalf("expected empty content type error, got %v", err) - } -} - -// ---- ContextConfig 验证传播测试 ---- - -func TestContextConfigValidatePropagatesCompactError(t *testing.T) { - t.Parallel() - - cfg := ContextConfig{ - Compact: CompactConfig{ - ManualKeepRecentMessages: 0, - MaxSummaryChars: 0, - }, - } - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "compact") { - t.Fatalf("expected compact validation error, got %v", err) - } -} - -// ---- ProviderIdentityFromConfig 默认 APIStyle 回退 ---- - -func TestProviderIdentityFromConfigDefaultsAPIStyleForOpenAICompat(t *testing.T) { - t.Parallel() - - cfg := ProviderConfig{ - Name: "test-openai", - Driver: "openaicompat", - BaseURL: "https://api.openai.com/v1", - APIKeyEnv: "TEST_KEY", - // APIStyle 故意为空 - } - - identity, err := providerIdentityFromConfig(cfg) - if err != nil { - t.Fatalf("providerIdentityFromConfig() error = %v", err) - } - if identity.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { - t.Fatalf("expected default api_style %q, got %q", providerpkg.OpenAICompatibleAPIStyleChatCompletions, identity.APIStyle) - } -} - -// ---- Qiniu 内置 provider 完整性 ---- - -func TestQiniuProviderConfig(t *testing.T) { - t.Parallel() - - provider := QiniuProvider() - if provider.Name != QiniuName { - t.Fatalf("expected name %q, got %q", QiniuName, provider.Name) - } - if provider.Driver != "openaicompat" { - t.Fatalf("expected driver openaicompat, got %q", provider.Driver) - } - if provider.BaseURL != QiniuDefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", QiniuDefaultBaseURL, provider.BaseURL) - } - if provider.Model != QiniuDefaultModel { - t.Fatalf("expected default model %q, got %q", QiniuDefaultModel, provider.Model) - } - if provider.APIKeyEnv != QiniuDefaultAPIKeyEnv { - t.Fatalf("expected API key env %q, got %q", QiniuDefaultAPIKeyEnv, provider.APIKeyEnv) - } - if provider.Source != ProviderSourceBuiltin { - t.Fatalf("expected builtin source, got %q", provider.Source) - } -} - -// ---- normalizeConfigKey / normalizeProviderDriver 基本覆盖 ---- - -func TestNormalizeConfigKey(t *testing.T) { - t.Parallel() - - if normalizeConfigKey(" OpenAI ") != "openai" { - t.Fatal("expected normalized lowercase trimmed key") - } - if normalizeConfigKey("") != "" { - t.Fatal("expected empty key to remain empty") - } -} - -func TestNormalizeProviderDriver(t *testing.T) { - t.Parallel() - - if normalizeProviderDriver(" OPENAICOMPAT ") != "openaicompat" { - t.Fatal("expected normalized driver") - } - if normalizeProviderDriver("") != "" { - t.Fatal("expected empty driver to remain empty") - } -} - -// ---- MCPServerConfig Clone 独立性(无 env 场景)---- - -func TestMCPServerConfigCloneWithoutEnv(t *testing.T) { - t.Parallel() - - original := MCPServerConfig{ - ID: "test", - Enabled: true, - Source: "stdio", - Stdio: MCPStdioConfig{Command: "cmd", Args: []string{"--help"}}, - Env: nil, - } - cloned := original.Clone() - cloned.Stdio.Args[0] = "--version" - if original.Stdio.Args[0] == cloned.Stdio.Args[0] { - t.Fatal("expected args clone to be independent") - } -} - -func TestMCPServerConfigCloneWithEmptyEnvSlice(t *testing.T) { - t.Parallel() - - original := MCPServerConfig{ - ID: "test", - Stdio: MCPStdioConfig{Command: "cmd"}, - Env: []MCPEnvVarConfig{}, - } - cloned := original.Clone() - if len(cloned.Env) != 0 { - t.Fatal("expected cloned env to be empty slice, not nil") - } -} - -// ---- ProviderConfig Resolve 错误链 ---- - -func TestProviderConfigResolveWrapsAPIKeyError(t *testing.T) { - restoreEnv(t, "UNRESOLVABLE_API_KEY_FOR_TEST") - _ = os.Unsetenv("UNRESOLVABLE_API_KEY_FOR_TEST") - - cfg := ProviderConfig{ - Name: "test", - Driver: "custom", - BaseURL: "https://example.com", - APIKeyEnv: "UNRESOLVABLE_API_KEY_FOR_TEST", - } - _, err := cfg.Resolve() - if err == nil || !strings.Contains(err.Error(), "UNRESOLVABLE_API_KEY_FOR_TEST") { - t.Fatalf("expected unresolved API key error, got %v", err) - } -} - -// ---- ToolsConfig Clone + ApplyDefaults 完整性 ---- - -func TestToolsConfigCloneIndependence(t *testing.T) { - t.Parallel() - - original := ToolsConfig{ - WebFetch: WebFetchConfig{ - MaxResponseBytes: 2048, - SupportedContentTypes: []string{"text/html"}, - }, - MCP: MCPConfig{ - Servers: []MCPServerConfig{{ID: "s1"}}, - }, - } - cloned := original.Clone() - cloned.WebFetch.MaxResponseBytes = 999 - cloned.MCP.Servers[0].ID = "s2" - - if original.WebFetch.MaxResponseBytes == cloned.WebFetch.MaxResponseBytes { - t.Fatal("expected WebFetch clone independence") - } - if original.MCP.Servers[0].ID == cloned.MCP.Servers[0].ID { - t.Fatal("expected MCP clone independence") - } -} - -// ---- cloneProviderConfig 模型独立性 ---- - -func TestCloneProviderConfigModelDescriptorsIndependence(t *testing.T) { - t.Parallel() - - original := ProviderConfig{ - Name: "test", - Models: []providertypes.ModelDescriptor{{ID: "model-a"}, {ID: "model-b"}}, - } - cloned := cloneProviderConfig(original) - cloned.Models[0].ID = "mutated" - if original.Models[0].ID == cloned.Models[0].ID { - t.Fatal("expected Models descriptor clone to be independent") - } -} - -// ---- ProviderByName 大小写不敏感查找 ---- - -func TestProviderByNameCaseInsensitive(t *testing.T) { - t.Parallel() - - cfg := &Config{Providers: []ProviderConfig{testDefaultProviderConfig()}} - found, err := cfg.ProviderByName("OPENAI") - if err != nil { - t.Fatalf("ProviderByName(OPENAI) error = %v", err) - } - if found.Name != testProviderName { - t.Fatalf("expected provider %q, got %q", testProviderName, found.Name) - } -} - -// ---- ResolveSelectedProvider 包装 ProviderByName 错误 ---- - -func TestResolveSelectedProviderWrapsNotFoundError(t *testing.T) { - t.Parallel() - - cfg := Config{ - SelectedProvider: "nonexistent", - Providers: []ProviderConfig{testDefaultProviderConfig()}, - } - _, err := ResolveSelectedProvider(cfg) - if err == nil || !strings.Contains(err.Error(), "not found") { - t.Fatalf("expected wrapped not found error, got %v", err) - } -} - -// ---- ContextConfig ApplyDefaults 传播到子配置 ---- - -func TestContextConfigApplyDefaultsNilReceiver(t *testing.T) { - t.Parallel() - - var ctxCfg *ContextConfig - ctxCfg.ApplyDefaults(ContextConfig{ - Compact: CompactConfig{ManualStrategy: CompactManualStrategyFullReplace}, - AutoCompact: AutoCompactConfig{InputTokenThreshold: 50000}, - }) - // 不应 panic -} - -// ---- CompactConfig ApplyDefaults 全部为零值时填充 ---- - -func TestCompactConfigApplyDefaultsAllZeroValues(t *testing.T) { - t.Parallel() - - cfg := CompactConfig{} - defaults := CompactConfig{ - ManualStrategy: CompactManualStrategyFullReplace, - ManualKeepRecentMessages: 15, - MaxSummaryChars: 2000, - } - cfg.ApplyDefaults(defaults) - if cfg.ManualStrategy != CompactManualStrategyFullReplace { - t.Fatalf("expected strategy %q, got %q", CompactManualStrategyFullReplace, cfg.ManualStrategy) - } - if cfg.ManualKeepRecentMessages != 15 { - t.Fatalf("expected messages=15, got %d", cfg.ManualKeepRecentMessages) - } - if cfg.MaxSummaryChars != 2000 { - t.Fatalf("expected chars=2000, got %d", cfg.MaxSummaryChars) - } -} - -// ---- WebFetchConfig ApplyDefaults 边界 ---- - -func TestWebFetchConfigApplyDefaultsNilReceiver(t *testing.T) { - t.Parallel() - - var wfCfg *WebFetchConfig - wfCfg.ApplyDefaults(WebFetchConfig{ - MaxResponseBytes: 512, - SupportedContentTypes: []string{"text/plain"}, - }) - // 不应 panic -} - -func TestWebFetchConfigApplyDefaultsPreservesExplicitMaxResponseBytes(t *testing.T) { - t.Parallel() - - cfg := WebFetchConfig{MaxResponseBytes: 9999} - cfg.ApplyDefaults(WebFetchConfig{MaxResponseBytes: 100}) - if cfg.MaxResponseBytes != 9999 { - t.Fatalf("expected explicit MaxResponseBytes=9999 to be preserved, got %d", cfg.MaxResponseBytes) - } -} - -// ---- Loader 取消上下文测试 ---- - -func TestLoaderLoadWithCanceledContext(t *testing.T) { - t.Parallel() - - loader := NewLoader(t.TempDir(), testDefaultConfig()) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - _, err := loader.Load(ctx) - if err == nil || !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled error for Load, got %v", err) - } -} - -func TestLoaderSaveWithCanceledContext(t *testing.T) { - t.Parallel() - - loader := NewLoader(t.TempDir(), testDefaultConfig()) - cfg := testDefaultConfig() - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - err := loader.Save(ctx, cfg) - if err == nil || !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled error for Save, got %v", err) - } -} - -// ---- Manager Update 恢复 providers 列表 ---- - -func TestManagerUpdateRestoresProvidersWhenCleared(t *testing.T) { - tempDir := t.TempDir() - manager := NewManager(NewLoader(tempDir, testDefaultConfig())) - if _, err := manager.Load(context.Background()); err != nil { - t.Fatalf("Load() error = %v", err) - } - - err := manager.Update(context.Background(), func(cfg *Config) error { - cfg.Providers = nil // 清空 provider 列表 - return nil - }) - if err != nil { - t.Fatalf("Update() error = %v", err) - } - - reloaded := manager.Get() - if len(reloaded.Providers) == 0 { - t.Fatal("expected providers to be restored from defaults after clearing") - } -} - -// ---- MCPServerConfig Clone 环境变量独立性 ---- - -func TestMCPServerConfigCloneEnvIndependence(t *testing.T) { - t.Parallel() - - original := MCPServerConfig{ - ID: "test", - Stdio: MCPStdioConfig{Command: "cmd"}, - Env: []MCPEnvVarConfig{ - {Name: "TOKEN", Value: "secret"}, - }, - } - cloned := original.Clone() - cloned.Env[0].Value = "modified" - if original.Env[0].Value == cloned.Env[0].Value { - t.Fatal("expected Env clone to be independent") - } -} - -// ---- cloneProviders 空切片 ---- - -func TestCloneProvidersEmptySlice(t *testing.T) { - t.Parallel() - - result := cloneProviders([]ProviderConfig{}) - if result != nil { - t.Fatalf("expected nil for empty input, got %+v", result) - } -} - -// ---- ProviderByName 空名称 ---- - -func TestProviderByNameEmptyNameOnNonEmptyList(t *testing.T) { - t.Parallel() - - cfg := &Config{Providers: []ProviderConfig{testDefaultProviderConfig()}} - _, err := cfg.ProviderByName("") - if err == nil || !strings.Contains(err.Error(), "not found") { - t.Fatalf("expected not found for empty name, got %v", err) - } -} - -// ---- ResolveAPIKey 空环境变量名 ---- - -func TestResolveAPIKeyEmptyEnvName(t *testing.T) { - t.Parallel() - - cfg := ProviderConfig{Name: "test"} - _, err := cfg.ResolveAPIKey() - if err == nil || !strings.Contains(err.Error(), "api_key_env is empty") { - t.Fatalf("expected empty api_key_env error, got %v", err) - } -} - -// ---- Config.Clone nil 接收者返回默认值 ---- - -func TestConfigCloneNilReceiverReturnsDefaults(t *testing.T) { - t.Parallel() - - var cfg *Config - cloned := cfg.Clone() - // Clone(nil) 走 StaticDefaults() 分支,providers 为空,cloneProviders 对空切片返回 nil - if cloned.Workdir == "" { - t.Fatal("expected cloned nil config to have default workdir") - } - if cloned.Shell == "" { - t.Fatal("expected cloned nil config to have default shell") - } - if cloned.MaxLoops == 0 { - t.Fatal("expected cloned nil config to have default max_loops") - } -} - -func TestMCPExposureConfigCloneIndependence(t *testing.T) { - t.Parallel() - - original := MCPExposureConfig{ - Allowlist: []string{"docs"}, - Denylist: []string{"admin.secret"}, - Agents: []MCPAgentExposureConfig{ - {Agent: "coder", Allowlist: []string{"docs.search"}}, - }, - } - - cloned := original.Clone() - cloned.Allowlist[0] = "changed" - cloned.Denylist[0] = "changed" - cloned.Agents[0].Agent = "planner" - cloned.Agents[0].Allowlist[0] = "changed" - - if original.Allowlist[0] != "docs" || original.Denylist[0] != "admin.secret" { - t.Fatalf("expected top-level slices to remain unchanged, got %+v", original) - } - if original.Agents[0].Agent != "coder" || original.Agents[0].Allowlist[0] != "docs.search" { - t.Fatalf("expected agent clone independence, got %+v", original.Agents[0]) - } -} - -func TestMCPExposureConfigApplyDefaultsNormalizesValues(t *testing.T) { - t.Parallel() - - cfg := MCPExposureConfig{ - Allowlist: []string{" Docs ", "", "MCP.Search.Live"}, - Denylist: []string{" Admin.Secret "}, - Agents: []MCPAgentExposureConfig{ - {Agent: " Planner ", Allowlist: []string{" Docs.Search ", ""}}, - }, - } - - cfg.ApplyDefaults(MCPExposureConfig{}) - if strings.Join(cfg.Allowlist, ",") != "docs,mcp.search.live" { - t.Fatalf("unexpected allowlist normalization: %+v", cfg.Allowlist) - } - if strings.Join(cfg.Denylist, ",") != "admin.secret" { - t.Fatalf("unexpected denylist normalization: %+v", cfg.Denylist) - } - if cfg.Agents[0].Agent != "Planner" { - t.Fatalf("expected agent to keep trimmed original casing, got %q", cfg.Agents[0].Agent) - } - if strings.Join(cfg.Agents[0].Allowlist, ",") != "docs.search" { - t.Fatalf("unexpected agent allowlist normalization: %+v", cfg.Agents[0].Allowlist) - } -} - -func TestMCPExposureConfigValidateRejectsInvalidRules(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cfg MCPExposureConfig - want string - }{ - { - name: "empty agent", - cfg: MCPExposureConfig{ - Agents: []MCPAgentExposureConfig{{Agent: " ", Allowlist: []string{"docs"}}}, - }, - want: "agents[0].agent is empty", - }, - { - name: "duplicate agent", - cfg: MCPExposureConfig{ - Agents: []MCPAgentExposureConfig{ - {Agent: "coder", Allowlist: []string{"docs"}}, - {Agent: "CoDeR", Allowlist: []string{"search"}}, - }, - }, - want: "duplicate agents[1].agent", - }, - { - name: "empty allowlist item", - cfg: MCPExposureConfig{Allowlist: []string{"docs", " "}}, - want: "allowlist[1] is empty", - }, - { - name: "empty denylist item", - cfg: MCPExposureConfig{Denylist: []string{" "}}, - want: "denylist[0] is empty", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - err := tt.cfg.Validate() - if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("expected %q, got %v", tt.want, err) - } - }) - } -} - -func TestMCPConfigClonePreservesExposureWithoutServers(t *testing.T) { - t.Parallel() - - cfg := MCPConfig{ - Exposure: MCPExposureConfig{ - Allowlist: []string{"docs"}, - Agents: []MCPAgentExposureConfig{ - {Agent: "coder", Allowlist: []string{"docs.search"}}, - }, - }, - } - - cloned := cfg.Clone() - if strings.Join(cloned.Exposure.Allowlist, ",") != "docs" { - t.Fatalf("expected exposure allowlist cloned, got %+v", cloned.Exposure) - } - if len(cloned.Servers) != 0 { - t.Fatalf("expected no servers, got %+v", cloned.Servers) - } -} - -func TestMCPConfigValidateWrapsExposureErrors(t *testing.T) { - t.Parallel() - - cfg := MCPConfig{ - Exposure: MCPExposureConfig{ - Allowlist: []string{" "}, - }, - } - - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "exposure: allowlist[0] is empty") { - t.Fatalf("expected wrapped exposure error, got %v", err) - } -} diff --git a/internal/config/provider.go b/internal/config/provider.go new file mode 100644 index 00000000..3f033c17 --- /dev/null +++ b/internal/config/provider.go @@ -0,0 +1,265 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +type ProviderSource string + +const ( + ProviderSourceBuiltin ProviderSource = "builtin" + ProviderSourceCustom ProviderSource = "custom" +) + +type ProviderConfig struct { + Name string `yaml:"name"` + Driver string `yaml:"driver"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + APIKeyEnv string `yaml:"api_key_env"` + APIStyle string `yaml:"-"` + DeploymentMode string `yaml:"-"` + APIVersion string `yaml:"-"` + Models []providertypes.ModelDescriptor `yaml:"-"` + Source ProviderSource `yaml:"-"` +} + +type ResolvedProviderConfig struct { + ProviderConfig + APIKey string `yaml:"-"` +} + +// ResolveSelectedProvider 解析当前配置中选中的 provider,并补全运行时所需的密钥信息。 +func ResolveSelectedProvider(cfg Config) (ResolvedProviderConfig, error) { + providerName := strings.TrimSpace(cfg.SelectedProvider) + if providerName == "" { + return ResolvedProviderConfig{}, errors.New("config: selected provider is empty") + } + + providerCfg, err := cfg.ProviderByName(providerName) + if err != nil { + return ResolvedProviderConfig{}, err + } + return providerCfg.Resolve() +} + +func (p ProviderConfig) Validate() error { + if strings.TrimSpace(p.Name) == "" { + return errors.New("provider name is empty") + } + if normalizeProviderDriver(p.Driver) == "" { + return fmt.Errorf("provider %q driver is empty", p.Name) + } + if strings.TrimSpace(p.BaseURL) == "" { + return fmt.Errorf("provider %q base_url is empty", p.Name) + } + if p.Source == ProviderSourceCustom && strings.TrimSpace(p.Model) != "" { + return fmt.Errorf("provider %q custom providers must not define model", p.Name) + } + if p.Source != ProviderSourceCustom && strings.TrimSpace(p.Model) == "" { + return fmt.Errorf("provider %q model is empty", p.Name) + } + if strings.TrimSpace(p.APIKeyEnv) == "" { + return fmt.Errorf("provider %q api_key_env is empty", p.Name) + } + if _, err := p.Identity(); err != nil { + return fmt.Errorf("provider %q: %w", p.Name, err) + } + return nil +} + +func (p ProviderConfig) Identity() (provider.ProviderIdentity, error) { + return providerIdentityFromConfig(p) +} + +func (p ProviderConfig) ResolveAPIKey() (string, error) { + envName := strings.TrimSpace(p.APIKeyEnv) + if envName == "" { + return "", fmt.Errorf("config: provider %q api_key_env is empty", p.Name) + } + + value := strings.TrimSpace(os.Getenv(envName)) + if value == "" { + return "", fmt.Errorf("config: environment variable %s is empty", envName) + } + + return value, nil +} + +func (p ProviderConfig) Resolve() (ResolvedProviderConfig, error) { + apiKey, err := p.ResolveAPIKey() + if err != nil { + return ResolvedProviderConfig{}, err + } + + return ResolvedProviderConfig{ + ProviderConfig: p, + APIKey: apiKey, + }, nil +} + +func cloneProviders(providers []ProviderConfig) []ProviderConfig { + if len(providers) == 0 { + return nil + } + + cloned := make([]ProviderConfig, 0, len(providers)) + for _, p := range providers { + cloned = append(cloned, cloneProviderConfig(p)) + } + return cloned +} + +// cloneProviderConfig 返回 provider 配置的深拷贝,避免模型元数据等切片在不同快照间共享。 +func cloneProviderConfig(provider ProviderConfig) ProviderConfig { + cloned := provider + cloned.Models = providertypes.CloneModelDescriptors(provider.Models) + return cloned +} + +func containsProviderName(providers []ProviderConfig, name string) bool { + target := normalizeProviderName(name) + if target == "" { + return false + } + for _, p := range providers { + if normalizeProviderName(p.Name) == target { + return true + } + } + return false +} + +// normalizeConfigKey 统一规范 config 层比较使用的字符串键,避免大小写和空白造成分支漂移。 +func normalizeConfigKey(value string) string { + return provider.NormalizeKey(value) +} + +// normalizeProviderName 统一规范 provider 名称,供 config 层查找、去重与比较逻辑复用。 +func normalizeProviderName(name string) string { + return provider.NormalizeKey(name) +} + +// normalizeProviderDriver 统一规范 driver 名称,供 config 层校验和配置解析分支复用。 +func normalizeProviderDriver(driver string) string { + return provider.NormalizeProviderDriver(driver) +} + +// providerIdentityFromConfig 根据 provider 配置构造用于去重与缓存的规范化连接身份。 +func providerIdentityFromConfig(cfg ProviderConfig) (provider.ProviderIdentity, error) { + apiStyle := cfg.APIStyle + if normalizeProviderDriver(cfg.Driver) == provider.DriverOpenAICompat && strings.TrimSpace(apiStyle) == "" { + apiStyle = provider.OpenAICompatibleAPIStyleChatCompletions + } + return provider.NormalizeProviderIdentity(provider.ProviderIdentity{ + Driver: cfg.Driver, + BaseURL: cfg.BaseURL, + APIStyle: apiStyle, + DeploymentMode: cfg.DeploymentMode, + APIVersion: cfg.APIVersion, + }) +} + +// ToRuntimeConfig 将解析后的 provider 配置收敛为 provider 层使用的最小运行时输入。 +func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { + return provider.RuntimeConfig{ + Name: p.Name, + Driver: p.Driver, + BaseURL: p.BaseURL, + DefaultModel: p.Model, + APIKey: p.APIKey, + APIStyle: p.APIStyle, + DeploymentMode: p.DeploymentMode, + APIVersion: p.APIVersion, + } +} + +const ( + OpenAIName = "openai" + OpenAIDefaultBaseURL = "https://api.openai.com/v1" + OpenAIDefaultModel = "gpt-4.1" + OpenAIDefaultAPIKeyEnv = "OPENAI_API_KEY" + + GeminiName = "gemini" + GeminiDefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" + GeminiDefaultModel = "gemini-2.5-flash" + GeminiDefaultAPIKeyEnv = "GEMINI_API_KEY" + + OpenLLName = "openll" + OpenLLDefaultBaseURL = "https://www.openll.top/v1" + OpenLLDefaultModel = "gpt-5.4" + OpenLLDefaultAPIKeyEnv = "AI_API_KEY" + + QiniuName = "qiniu" + QiniuDefaultBaseURL = "https://api.qnaigc.com/v1" + QiniuDefaultModel = "openai/gpt-5" + QiniuDefaultAPIKeyEnv = "QINIU_API_KEY" +) + +// OpenAIProvider returns the builtin OpenAI provider definition. +func OpenAIProvider() ProviderConfig { + return ProviderConfig{ + Name: OpenAIName, + Driver: provider.DriverOpenAICompat, + BaseURL: OpenAIDefaultBaseURL, + Model: OpenAIDefaultModel, + APIKeyEnv: OpenAIDefaultAPIKeyEnv, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + Source: ProviderSourceBuiltin, + } +} + +// GeminiProvider returns the builtin Gemini provider definition. +func GeminiProvider() ProviderConfig { + return ProviderConfig{ + Name: GeminiName, + Driver: provider.DriverOpenAICompat, + BaseURL: GeminiDefaultBaseURL, + Model: GeminiDefaultModel, + APIKeyEnv: GeminiDefaultAPIKeyEnv, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + Source: ProviderSourceBuiltin, + } +} + +// OpenLLProvider returns the builtin OpenLL provider definition. +func OpenLLProvider() ProviderConfig { + return ProviderConfig{ + Name: OpenLLName, + Driver: provider.DriverOpenAICompat, + BaseURL: OpenLLDefaultBaseURL, + Model: OpenLLDefaultModel, + APIKeyEnv: OpenLLDefaultAPIKeyEnv, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + Source: ProviderSourceBuiltin, + } +} + +// QiniuProvider returns the builtin Qiniu provider definition. +func QiniuProvider() ProviderConfig { + return ProviderConfig{ + Name: QiniuName, + Driver: provider.DriverOpenAICompat, + BaseURL: QiniuDefaultBaseURL, + Model: QiniuDefaultModel, + APIKeyEnv: QiniuDefaultAPIKeyEnv, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + Source: ProviderSourceBuiltin, + } +} + +// DefaultProviders returns all builtin provider definitions. +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), + GeminiProvider(), + OpenLLProvider(), + QiniuProvider(), + } +} diff --git a/internal/config/provider_runtime.go b/internal/config/provider_runtime.go deleted file mode 100644 index 2909f1d4..00000000 --- a/internal/config/provider_runtime.go +++ /dev/null @@ -1,51 +0,0 @@ -package config - -import ( - "strings" - - "neo-code/internal/provider" -) - -// normalizeConfigKey 统一规范 config 层比较使用的字符串键,避免大小写和空白造成分支漂移。 -func normalizeConfigKey(value string) string { - return provider.NormalizeKey(value) -} - -// normalizeProviderName 统一规范 provider 名称,供 config 层查找、去重与比较逻辑复用。 -func normalizeProviderName(name string) string { - return provider.NormalizeKey(name) -} - -// normalizeProviderDriver 统一规范 driver 名称,供 config 层校验和配置解析分支复用。 -func normalizeProviderDriver(driver string) string { - return provider.NormalizeProviderDriver(driver) -} - -// providerIdentityFromConfig 根据 provider 配置构造用于去重与缓存的规范化连接身份。 -func providerIdentityFromConfig(cfg ProviderConfig) (provider.ProviderIdentity, error) { - apiStyle := cfg.APIStyle - if normalizeProviderDriver(cfg.Driver) == provider.DriverOpenAICompat && strings.TrimSpace(apiStyle) == "" { - apiStyle = provider.OpenAICompatibleAPIStyleChatCompletions - } - return provider.NormalizeProviderIdentity(provider.ProviderIdentity{ - Driver: cfg.Driver, - BaseURL: cfg.BaseURL, - APIStyle: apiStyle, - DeploymentMode: cfg.DeploymentMode, - APIVersion: cfg.APIVersion, - }) -} - -// ToRuntimeConfig 将解析后的 provider 配置收敛为 provider 层使用的最小运行时输入。 -func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { - return provider.RuntimeConfig{ - Name: p.Name, - Driver: p.Driver, - BaseURL: p.BaseURL, - DefaultModel: p.Model, - APIKey: p.APIKey, - APIStyle: p.APIStyle, - DeploymentMode: p.DeploymentMode, - APIVersion: p.APIVersion, - } -} diff --git a/internal/config/provider_runtime_test.go b/internal/config/provider_runtime_test.go deleted file mode 100644 index 9e1cfd09..00000000 --- a/internal/config/provider_runtime_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package config - -import ( - "testing" - - providerpkg "neo-code/internal/provider" -) - -func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { - t.Parallel() - - resolved := ResolvedProviderConfig{ - ProviderConfig: ProviderConfig{ - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://llm.example.com/v1", - Model: "server-default", - APIStyle: "responses", - DeploymentMode: "ignored", - APIVersion: "ignored", - }, - APIKey: "secret-key", - } - - got := resolved.ToRuntimeConfig() - want := providerpkg.RuntimeConfig{ - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://llm.example.com/v1", - DefaultModel: "server-default", - APIKey: "secret-key", - APIStyle: "responses", - DeploymentMode: "ignored", - APIVersion: "ignored", - } - - if got != want { - t.Fatalf("ToRuntimeConfig() = %+v, want %+v", got, want) - } -} diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go new file mode 100644 index 00000000..22525bb5 --- /dev/null +++ b/internal/config/provider_test.go @@ -0,0 +1,395 @@ +package config + +import ( + "os" + "strings" + "testing" + + providerpkg "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +func TestContainsProviderName(t *testing.T) { + t.Parallel() + + providers := []ProviderConfig{ + {Name: "OpenAI"}, + {Name: "Gemini"}, + } + if !containsProviderName(providers, "openai") { + t.Fatal("expected openai to be found (case insensitive)") + } + if !containsProviderName(providers, "OPENAI") { + t.Fatal("expected OPENAI to be found") + } + if containsProviderName(providers, "Anthropic") { + t.Fatal("expected Anthropic to not be found") + } + if containsProviderName(nil, "openai") { + t.Fatal("expected nil providers to return false") + } + if containsProviderName(providers, "") { + t.Fatal("expected empty name to return false") + } + if containsProviderName(providers, " ") { + t.Fatal("expected whitespace name to return false") + } +} + +func TestResolveSelectedProviderEmptyString(t *testing.T) { + t.Parallel() + + cfg := Config{ + SelectedProvider: "", + Providers: []ProviderConfig{testDefaultProviderConfig()}, + } + _, err := ResolveSelectedProvider(cfg) + if err == nil || !strings.Contains(err.Error(), "selected provider is empty") { + t.Fatalf("expected empty selected provider error, got %v", err) + } +} + +func TestResolveSelectedProviderNotFound(t *testing.T) { + t.Parallel() + + cfg := Config{ + SelectedProvider: "nonexistent-provider", + Providers: []ProviderConfig{testDefaultProviderConfig()}, + } + _, err := ResolveSelectedProvider(cfg) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not found error, got %v", err) + } +} + +func TestProviderConfigIdentity(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{ + Name: "test-openai", + Driver: "openaicompat", + BaseURL: "https://api.openai.com/v1", + Model: "gpt-4o", + APIKeyEnv: "TEST_KEY", + APIStyle: "chat_completions", + } + + identity, err := cfg.Identity() + if err != nil { + t.Fatalf("Identity() error = %v", err) + } + if identity.Driver != "openaicompat" { + t.Fatalf("expected driver openaicompat, got %q", identity.Driver) + } + if identity.BaseURL != "https://api.openai.com/v1" { + t.Fatalf("expected base URL, got %q", identity.BaseURL) + } + if identity.APIStyle != "chat_completions" { + t.Fatalf("expected api_style chat_completions, got %q", identity.APIStyle) + } +} + +func TestProviderConfigResolveAPIKeyEmptyEnvName(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{Name: "test", APIKeyEnv: ""} + _, err := cfg.ResolveAPIKey() + if err == nil || !strings.Contains(err.Error(), "api_key_env is empty") { + t.Fatalf("expected empty api_key_env error, got %v", err) + } +} + +func TestProviderIdentityFromConfigDefaultsAPIStyleForOpenAICompat(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{ + Name: "test-openai", + Driver: "openaicompat", + BaseURL: "https://api.openai.com/v1", + APIKeyEnv: "TEST_KEY", + } + + identity, err := providerIdentityFromConfig(cfg) + if err != nil { + t.Fatalf("providerIdentityFromConfig() error = %v", err) + } + if identity.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { + t.Fatalf("expected default api_style %q, got %q", providerpkg.OpenAICompatibleAPIStyleChatCompletions, identity.APIStyle) + } +} + +func TestQiniuProviderConfig(t *testing.T) { + t.Parallel() + + provider := QiniuProvider() + if provider.Name != QiniuName { + t.Fatalf("expected name %q, got %q", QiniuName, provider.Name) + } + if provider.Driver != "openaicompat" { + t.Fatalf("expected driver openaicompat, got %q", provider.Driver) + } + if provider.BaseURL != QiniuDefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", QiniuDefaultBaseURL, provider.BaseURL) + } + if provider.Model != QiniuDefaultModel { + t.Fatalf("expected default model %q, got %q", QiniuDefaultModel, provider.Model) + } + if provider.APIKeyEnv != QiniuDefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", QiniuDefaultAPIKeyEnv, provider.APIKeyEnv) + } + if provider.Source != ProviderSourceBuiltin { + t.Fatalf("expected builtin source, got %q", provider.Source) + } +} + +func TestNormalizeConfigKey(t *testing.T) { + t.Parallel() + + if normalizeConfigKey(" OpenAI ") != "openai" { + t.Fatal("expected normalized lowercase trimmed key") + } + if normalizeConfigKey("") != "" { + t.Fatal("expected empty key to remain empty") + } +} + +func TestNormalizeProviderDriver(t *testing.T) { + t.Parallel() + + if normalizeProviderDriver(" OPENAICOMPAT ") != "openaicompat" { + t.Fatal("expected normalized driver") + } + if normalizeProviderDriver("") != "" { + t.Fatal("expected empty driver to remain empty") + } +} + +func TestProviderConfigResolveWrapsAPIKeyError(t *testing.T) { + restoreEnv(t, "UNRESOLVABLE_API_KEY_FOR_TEST") + _ = os.Unsetenv("UNRESOLVABLE_API_KEY_FOR_TEST") + + cfg := ProviderConfig{ + Name: "test", + Driver: "custom", + BaseURL: "https://example.com", + APIKeyEnv: "UNRESOLVABLE_API_KEY_FOR_TEST", + } + _, err := cfg.Resolve() + if err == nil || !strings.Contains(err.Error(), "UNRESOLVABLE_API_KEY_FOR_TEST") { + t.Fatalf("expected unresolved API key error, got %v", err) + } +} + +func TestCloneProviderConfigModelDescriptorsIndependence(t *testing.T) { + t.Parallel() + + original := ProviderConfig{ + Name: "test", + Models: []providertypes.ModelDescriptor{{ID: "model-a"}, {ID: "model-b"}}, + } + cloned := cloneProviderConfig(original) + cloned.Models[0].ID = "mutated" + if original.Models[0].ID == cloned.Models[0].ID { + t.Fatal("expected Models descriptor clone to be independent") + } +} + +func TestProviderByNameCaseInsensitive(t *testing.T) { + t.Parallel() + + cfg := &Config{Providers: []ProviderConfig{testDefaultProviderConfig()}} + found, err := cfg.ProviderByName("OPENAI") + if err != nil { + t.Fatalf("ProviderByName(OPENAI) error = %v", err) + } + if found.Name != testProviderName { + t.Fatalf("expected provider %q, got %q", testProviderName, found.Name) + } +} + +func TestResolveSelectedProviderWrapsNotFoundError(t *testing.T) { + t.Parallel() + + cfg := Config{ + SelectedProvider: "nonexistent", + Providers: []ProviderConfig{testDefaultProviderConfig()}, + } + _, err := ResolveSelectedProvider(cfg) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected wrapped not found error, got %v", err) + } +} + +func TestCloneProvidersEmptySlice(t *testing.T) { + t.Parallel() + + result := cloneProviders([]ProviderConfig{}) + if result != nil { + t.Fatalf("expected nil for empty input, got %+v", result) + } +} + +func TestProviderByNameEmptyNameOnNonEmptyList(t *testing.T) { + t.Parallel() + + cfg := &Config{Providers: []ProviderConfig{testDefaultProviderConfig()}} + _, err := cfg.ProviderByName("") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not found for empty name, got %v", err) + } +} + +func TestResolveAPIKeyEmptyEnvName(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{Name: "test"} + _, err := cfg.ResolveAPIKey() + if err == nil || !strings.Contains(err.Error(), "api_key_env is empty") { + t.Fatalf("expected empty api_key_env error, got %v", err) + } +} + +func TestDefaultProvidersReturnsAllBuiltins(t *testing.T) { + t.Parallel() + + providers := DefaultProviders() + if len(providers) != 4 { + t.Fatalf("expected 4 builtin providers, got %d", len(providers)) + } + + expectedNames := []string{OpenAIName, GeminiName, OpenLLName, QiniuName} + for i, provider := range providers { + if provider.Name != expectedNames[i] { + t.Fatalf("expected provider[%d] name %q, got %q", i, expectedNames[i], provider.Name) + } + } +} + +func TestOpenAIProviderConfig(t *testing.T) { + t.Parallel() + + provider := OpenAIProvider() + + if provider.Name != OpenAIName { + t.Fatalf("expected name %q, got %q", OpenAIName, provider.Name) + } + if provider.Driver != "openaicompat" { + t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) + } + if provider.BaseURL != OpenAIDefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", OpenAIDefaultBaseURL, provider.BaseURL) + } + if provider.Model != OpenAIDefaultModel { + t.Fatalf("expected default model %q, got %q", OpenAIDefaultModel, provider.Model) + } + if provider.APIKeyEnv != OpenAIDefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", OpenAIDefaultAPIKeyEnv, provider.APIKeyEnv) + } +} + +func TestGeminiProviderConfig(t *testing.T) { + t.Parallel() + + provider := GeminiProvider() + + if provider.Name != GeminiName { + t.Fatalf("expected name %q, got %q", GeminiName, provider.Name) + } + if provider.Driver != "openaicompat" { + t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) + } + if provider.BaseURL != GeminiDefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", GeminiDefaultBaseURL, provider.BaseURL) + } + if provider.Model != GeminiDefaultModel { + t.Fatalf("expected default model %q, got %q", GeminiDefaultModel, provider.Model) + } + if provider.APIKeyEnv != GeminiDefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", GeminiDefaultAPIKeyEnv, provider.APIKeyEnv) + } +} + +func TestOpenLLProviderConfig(t *testing.T) { + t.Parallel() + + provider := OpenLLProvider() + + if provider.Name != OpenLLName { + t.Fatalf("expected name %q, got %q", OpenLLName, provider.Name) + } + if provider.Driver != "openaicompat" { + t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) + } + if provider.BaseURL != OpenLLDefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", OpenLLDefaultBaseURL, provider.BaseURL) + } + if provider.Model != OpenLLDefaultModel { + t.Fatalf("expected default model %q, got %q", OpenLLDefaultModel, provider.Model) + } + if provider.APIKeyEnv != OpenLLDefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", OpenLLDefaultAPIKeyEnv, provider.APIKeyEnv) + } +} + +func TestProviderDefaultsAreIndependent(t *testing.T) { + t.Parallel() + + provider1 := OpenAIProvider() + provider1.Model = "modified-model" + + provider2 := OpenAIProvider() + if provider2.Model == "modified-model" { + t.Fatal("expected builtin provider defaults to be independent between calls") + } + if provider2.Model != OpenAIDefaultModel { + t.Fatalf("expected default model %q, got %q", OpenAIDefaultModel, provider2.Model) + } +} + +func TestDefaultProvidersReturnsIndependentSlices(t *testing.T) { + t.Parallel() + + providers1 := DefaultProviders() + providers1[0].Name = "modified" + + providers2 := DefaultProviders() + if providers2[0].Name == "modified" { + t.Fatal("expected DefaultProviders to return independent slices") + } + if providers2[0].Name != OpenAIName { + t.Fatalf("expected first provider name %q, got %q", OpenAIName, providers2[0].Name) + } +} + +func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { + t.Parallel() + + resolved := ResolvedProviderConfig{ + ProviderConfig: ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + Model: "server-default", + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + }, + APIKey: "secret-key", + } + + got := resolved.ToRuntimeConfig() + want := providerpkg.RuntimeConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + DefaultModel: "server-default", + APIKey: "secret-key", + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + } + + if got != want { + t.Fatalf("ToRuntimeConfig() = %+v, want %+v", got, want) + } +} diff --git a/internal/config/tools.go b/internal/config/tools.go new file mode 100644 index 00000000..c1a257ef --- /dev/null +++ b/internal/config/tools.go @@ -0,0 +1,141 @@ +package config + +import ( + "errors" + "fmt" + "mime" + "strings" +) + +const ( + DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 +) + +var defaultWebFetchSupportedContentTypes = []string{ + "text/html", + "application/xhtml+xml", + "text/plain", + "application/json", + "application/xml", + "text/xml", +} + +// DefaultWebFetchSupportedContentTypes 返回 WebFetch 允许抓取的默认内容类型列表副本。 +func DefaultWebFetchSupportedContentTypes() []string { + return append([]string(nil), defaultWebFetchSupportedContentTypes...) +} + +type ToolsConfig struct { + WebFetch WebFetchConfig `yaml:"webfetch,omitempty"` + MCP MCPConfig `yaml:"mcp,omitempty"` +} + +type WebFetchConfig struct { + MaxResponseBytes int64 `yaml:"max_response_bytes,omitempty"` + SupportedContentTypes []string `yaml:"supported_content_types,omitempty"` +} + +func defaultWebFetchConfig() WebFetchConfig { + return WebFetchConfig{ + MaxResponseBytes: DefaultWebFetchMaxResponseBytes, + SupportedContentTypes: DefaultWebFetchSupportedContentTypes(), + } +} + +func (c ToolsConfig) Clone() ToolsConfig { + return ToolsConfig{ + WebFetch: c.WebFetch.Clone(), + MCP: c.MCP.Clone(), + } +} + +func (c *ToolsConfig) ApplyDefaults(defaults ToolsConfig) { + if c == nil { + return + } + + c.WebFetch.ApplyDefaults(defaults.WebFetch) + c.MCP.ApplyDefaults(defaults.MCP) +} + +func (c ToolsConfig) Validate() error { + if err := c.WebFetch.Validate(); err != nil { + return fmt.Errorf("webfetch: %w", err) + } + if err := c.MCP.Validate(); err != nil { + return fmt.Errorf("mcp: %w", err) + } + return nil +} + +func (c WebFetchConfig) Clone() WebFetchConfig { + clone := c + clone.SupportedContentTypes = append([]string(nil), c.SupportedContentTypes...) + return clone +} + +func (c *WebFetchConfig) ApplyDefaults(defaults WebFetchConfig) { + if c == nil { + return + } + + if c.MaxResponseBytes <= 0 { + c.MaxResponseBytes = defaults.MaxResponseBytes + } + c.SupportedContentTypes = normalizeContentTypes(c.SupportedContentTypes, defaults.SupportedContentTypes) +} + +func (c WebFetchConfig) Validate() error { + if c.MaxResponseBytes <= 0 { + return errors.New("max_response_bytes must be greater than 0") + } + if len(c.SupportedContentTypes) == 0 { + return errors.New("supported_content_types is empty") + } + + for i, contentType := range c.SupportedContentTypes { + if normalizeContentType(contentType) == "" { + return fmt.Errorf("supported_content_types[%d] is empty", i) + } + } + return nil +} + +func normalizeContentTypes(values []string, defaults []string) []string { + source := values + if len(source) == 0 { + source = defaults + } + + normalized := make([]string, 0, len(source)) + seen := make(map[string]struct{}, len(source)) + for _, value := range source { + contentType := normalizeContentType(value) + if contentType == "" { + continue + } + if _, exists := seen[contentType]; exists { + continue + } + seen[contentType] = struct{}{} + normalized = append(normalized, contentType) + } + return normalized +} + +func normalizeContentType(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + + mediaType, _, err := mime.ParseMediaType(trimmed) + if err == nil { + return mediaType + } + + if index := strings.Index(trimmed, ";"); index >= 0 { + return strings.TrimSpace(trimmed[:index]) + } + return trimmed +} diff --git a/internal/config/tools_test.go b/internal/config/tools_test.go new file mode 100644 index 00000000..051cac5d --- /dev/null +++ b/internal/config/tools_test.go @@ -0,0 +1,159 @@ +package config + +import ( + "reflect" + "strings" + "testing" +) + +func TestDefaultWebFetchSupportedContentTypesReturnsCopy(t *testing.T) { + t.Parallel() + + types1 := DefaultWebFetchSupportedContentTypes() + types2 := DefaultWebFetchSupportedContentTypes() + if &types1[0] == &types2[0] { + t.Fatal("expected independent slices") + } + if len(types1) == 0 { + t.Fatal("expected non-empty default content types") + } +} + +func TestWebFetchConfigCloneIndependence(t *testing.T) { + t.Parallel() + + original := WebFetchConfig{ + MaxResponseBytes: 4096, + SupportedContentTypes: []string{"text/html", "application/json"}, + } + cloned := original.Clone() + + cloned.MaxResponseBytes = 1024 + cloned.SupportedContentTypes[0] = "text/plain" + + if original.MaxResponseBytes == cloned.MaxResponseBytes { + t.Fatal("expected MaxResponseBytes clone to be independent") + } + if original.SupportedContentTypes[0] == cloned.SupportedContentTypes[0] { + t.Fatal("expected SupportedContentTypes clone to be independent") + } +} + +func TestWebFetchConfigValidateRejectsEmptyContentType(t *testing.T) { + t.Parallel() + + cfg := WebFetchConfig{ + MaxResponseBytes: 1024, + SupportedContentTypes: []string{"text/html", "", "application/json"}, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "[1] is empty") { + t.Fatalf("expected empty content type error, got %v", err) + } +} + +func TestNormalizeContentType(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {input: "text/html", expected: "text/html"}, + {input: " Text/HTML ", expected: "text/html"}, + {input: "text/html; charset=utf-8", expected: "text/html"}, + {input: "APPLICATION/JSON", expected: "application/json"}, + {input: " ", expected: ""}, + {input: "", expected: ""}, + {input: "application/x-www-form-urlencoded; charset=utf-8", expected: "application/x-www-form-urlencoded"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + got := normalizeContentType(tt.input) + if got != tt.expected { + t.Fatalf("normalizeContentType(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestNormalizeContentTypesBehaviors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + values []string + defaults []string + want []string + }{ + { + name: "deduplicates and cleans", + values: []string{"TEXT/HTML", " text/plain ", "text/html", "", " "}, + defaults: []string{"text/html", "text/plain"}, + want: []string{"text/html", "text/plain"}, + }, + { + name: "drops empty without fallback", + values: []string{"", " "}, + defaults: []string{"text/html", "text/plain"}, + want: []string{}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := normalizeContentTypes(tt.values, tt.defaults) + if !reflect.DeepEqual(result, tt.want) { + t.Fatalf("normalizeContentTypes(%+v) = %+v, want %+v", tt.values, result, tt.want) + } + }) + } +} + +func TestToolsConfigCloneIndependence(t *testing.T) { + t.Parallel() + + original := ToolsConfig{ + WebFetch: WebFetchConfig{ + MaxResponseBytes: 2048, + SupportedContentTypes: []string{"text/html"}, + }, + MCP: MCPConfig{ + Servers: []MCPServerConfig{{ID: "s1"}}, + }, + } + cloned := original.Clone() + cloned.WebFetch.MaxResponseBytes = 999 + cloned.MCP.Servers[0].ID = "s2" + + if original.WebFetch.MaxResponseBytes == cloned.WebFetch.MaxResponseBytes { + t.Fatal("expected WebFetch clone independence") + } + if original.MCP.Servers[0].ID == cloned.MCP.Servers[0].ID { + t.Fatal("expected MCP clone independence") + } +} + +func TestWebFetchConfigApplyDefaultsNilReceiver(t *testing.T) { + t.Parallel() + + var wfCfg *WebFetchConfig + wfCfg.ApplyDefaults(WebFetchConfig{ + MaxResponseBytes: 512, + SupportedContentTypes: []string{"text/plain"}, + }) +} + +func TestWebFetchConfigApplyDefaultsPreservesExplicitMaxResponseBytes(t *testing.T) { + t.Parallel() + + cfg := WebFetchConfig{MaxResponseBytes: 9999} + cfg.ApplyDefaults(WebFetchConfig{MaxResponseBytes: 100}) + if cfg.MaxResponseBytes != 9999 { + t.Fatalf("expected explicit MaxResponseBytes=9999 to be preserved, got %d", cfg.MaxResponseBytes) + } +}