From b56a1ef7b7a99d7366dfadfe894a2be3f96d5965 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Mon, 30 Mar 2026 23:43:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?pref(provider):=E4=BC=98=E5=8C=96provider?= =?UTF-8?q?=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/builtin_providers.go | 82 ++++ internal/config/builtin_providers_test.go | 180 +++++++++ internal/config/model.go | 8 +- internal/provider/builtin/builtin.go | 17 +- internal/provider/builtin/builtin_test.go | 78 +++- internal/provider/gemini/gemini.go | 31 -- internal/provider/gemini/gemini_test.go | 31 -- internal/provider/openai/openai.go | 29 +- internal/provider/openai/openai_test.go | 48 +-- internal/provider/openll/openll.go | 30 -- internal/provider/openll/openll_test.go | 43 -- internal/provider/registry_test.go | 20 +- internal/provider/service.go | 14 +- internal/provider/service_test.go | 472 ++++++++++++++++++++++ internal/runtime/runtime.go | 2 +- internal/runtime/runtime_test.go | 5 +- internal/tui/update_test.go | 11 +- 17 files changed, 847 insertions(+), 254 deletions(-) create mode 100644 internal/config/builtin_providers.go create mode 100644 internal/config/builtin_providers_test.go delete mode 100644 internal/provider/gemini/gemini.go delete mode 100644 internal/provider/gemini/gemini_test.go delete mode 100644 internal/provider/openll/openll.go delete mode 100644 internal/provider/openll/openll_test.go create mode 100644 internal/provider/service_test.go diff --git a/internal/config/builtin_providers.go b/internal/config/builtin_providers.go new file mode 100644 index 00000000..8d7fa8b9 --- /dev/null +++ b/internal/config/builtin_providers.go @@ -0,0 +1,82 @@ +package config + +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" +) + +var openAIModels = []string{ + OpenAIDefaultModel, + "gpt-4o", + "gpt-5.4", + "gpt-5.3-codex", +} + +var geminiModels = []string{ + GeminiDefaultModel, + "gemini-2.5-pro", + "gemini-2.0-flash", +} + +var openLLModels = []string{ + OpenLLDefaultModel, + "gpt-5.3-codex", + "gpt-5.3-turbo", +} + +// OpenAIProvider 返回 OpenAI provider 的默认配置。 +func OpenAIProvider() ProviderConfig { + return ProviderConfig{ + Name: OpenAIName, + Driver: "openai", + BaseURL: OpenAIDefaultBaseURL, + Model: OpenAIDefaultModel, + Models: append([]string(nil), openAIModels...), + APIKeyEnv: OpenAIDefaultAPIKeyEnv, + } +} + +// GeminiProvider 返回 Gemini provider 的默认配置。 +func GeminiProvider() ProviderConfig { + return ProviderConfig{ + Name: GeminiName, + Driver: "openai", + BaseURL: GeminiDefaultBaseURL, + Model: GeminiDefaultModel, + Models: append([]string(nil), geminiModels...), + APIKeyEnv: GeminiDefaultAPIKeyEnv, + } +} + +// OpenLLProvider 返回 OpenLL provider 的默认配置。 +func OpenLLProvider() ProviderConfig { + return ProviderConfig{ + Name: OpenLLName, + Driver: "openai", + BaseURL: OpenLLDefaultBaseURL, + Model: OpenLLDefaultModel, + Models: append([]string(nil), openLLModels...), + APIKeyEnv: OpenLLDefaultAPIKeyEnv, + } +} + +// DefaultProviders 返回所有内置 provider 配置列表。 +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), + GeminiProvider(), + OpenLLProvider(), + } +} diff --git a/internal/config/builtin_providers_test.go b/internal/config/builtin_providers_test.go new file mode 100644 index 00000000..a9fe0c83 --- /dev/null +++ b/internal/config/builtin_providers_test.go @@ -0,0 +1,180 @@ +package config + +import ( + "testing" +) + +func TestDefaultProvidersReturnsAllBuiltins(t *testing.T) { + t.Parallel() + + providers := DefaultProviders() + if len(providers) != 3 { + t.Fatalf("expected 3 builtin providers, got %d", len(providers)) + } + + expectedNames := []string{OpenAIName, GeminiName, OpenLLName} + 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 != "openai" { + t.Fatalf("expected driver %q, got %q", "openai", 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) + } + if len(provider.Models) == 0 { + t.Fatal("expected non-empty models list") + } + if !ContainsModelID(provider.Models, OpenAIDefaultModel) { + t.Fatalf("expected models to contain default model %q", OpenAIDefaultModel) + } +} + +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 != "openai" { + t.Fatalf("expected driver %q, got %q", "openai", 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) + } + if len(provider.Models) == 0 { + t.Fatal("expected non-empty models list") + } + if !ContainsModelID(provider.Models, GeminiDefaultModel) { + t.Fatalf("expected models to contain default model %q", GeminiDefaultModel) + } +} + +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 != "openai" { + t.Fatalf("expected driver %q, got %q", "openai", 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) + } + if len(provider.Models) == 0 { + t.Fatal("expected non-empty models list") + } + if !ContainsModelID(provider.Models, OpenLLDefaultModel) { + t.Fatalf("expected models to contain default model %q", OpenLLDefaultModel) + } +} + +func TestProviderModelsAreImmutable(t *testing.T) { + t.Parallel() + + // Verify that modifying returned models slice doesn't affect future calls + provider1 := OpenAIProvider() + provider1.Models[0] = "modified-model" + + provider2 := OpenAIProvider() + if provider2.Models[0] == "modified-model" { + t.Fatal("expected models slice to be independent between calls") + } + if provider2.Models[0] != OpenAIDefaultModel { + t.Fatalf("expected first model %q, got %q", OpenAIDefaultModel, provider2.Models[0]) + } +} + +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 TestProviderConstants(t *testing.T) { + t.Parallel() + + // Verify all constants are non-empty + if OpenAIName == "" { + t.Fatal("OpenAIName should not be empty") + } + if OpenAIDefaultBaseURL == "" { + t.Fatal("OpenAIDefaultBaseURL should not be empty") + } + if OpenAIDefaultModel == "" { + t.Fatal("OpenAIDefaultModel should not be empty") + } + if OpenAIDefaultAPIKeyEnv == "" { + t.Fatal("OpenAIDefaultAPIKeyEnv should not be empty") + } + + if GeminiName == "" { + t.Fatal("GeminiName should not be empty") + } + if GeminiDefaultBaseURL == "" { + t.Fatal("GeminiDefaultBaseURL should not be empty") + } + if GeminiDefaultModel == "" { + t.Fatal("GeminiDefaultModel should not be empty") + } + if GeminiDefaultAPIKeyEnv == "" { + t.Fatal("GeminiDefaultAPIKeyEnv should not be empty") + } + + if OpenLLName == "" { + t.Fatal("OpenLLName should not be empty") + } + if OpenLLDefaultBaseURL == "" { + t.Fatal("OpenLLDefaultBaseURL should not be empty") + } + if OpenLLDefaultModel == "" { + t.Fatal("OpenLLDefaultModel should not be empty") + } + if OpenLLDefaultAPIKeyEnv == "" { + t.Fatal("OpenLLDefaultAPIKeyEnv should not be empty") + } +} diff --git a/internal/config/model.go b/internal/config/model.go index 3273697a..c0b57f0b 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -108,7 +108,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { c.CurrentModel = defaults.CurrentModel } } - if selected, err := c.SelectedProviderConfig(); err == nil && !containsModelID(selected.SupportedModels(), c.CurrentModel) { + if selected, err := c.SelectedProviderConfig(); err == nil && !ContainsModelID(selected.SupportedModels(), c.CurrentModel) { c.CurrentModel = selected.Model } if strings.TrimSpace(c.Workdir) == "" { @@ -168,7 +168,7 @@ func (c *Config) Validate() error { if strings.TrimSpace(selected.Model) == "" { return fmt.Errorf("config: selected provider %q has empty model", selected.Name) } - if !containsModelID(selected.SupportedModels(), c.CurrentModel) { + if !ContainsModelID(selected.SupportedModels(), c.CurrentModel) { return fmt.Errorf("config: current_model %q is not supported by provider %q", c.CurrentModel, selected.Name) } if err := c.Tools.Validate(); err != nil { @@ -220,7 +220,7 @@ func (p ProviderConfig) Validate() error { if len(models) == 0 { return fmt.Errorf("provider %q models is empty", p.Name) } - if !containsModelID(models, p.Model) { + if !ContainsModelID(models, p.Model) { return fmt.Errorf("provider %q default model %q is not in models", p.Name, strings.TrimSpace(p.Model)) } } @@ -492,7 +492,7 @@ func normalizeModelIDs(models []string) []string { return normalized } -func containsModelID(models []string, model string) bool { +func ContainsModelID(models []string, model string) bool { target := strings.ToLower(strings.TrimSpace(model)) if target == "" { return false diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index 3d4fcb61..a2c128b8 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -5,21 +5,18 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" - "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" - "neo-code/internal/provider/openll" ) +// DefaultConfig 返回默认配置,包含所有内置 provider。 func DefaultConfig() *config.Config { cfg := config.Default() - defaultProvider := openai.BuiltinConfig() - cfg.Providers = []config.ProviderConfig{ - defaultProvider, - gemini.BuiltinConfig(), - openll.BuiltinConfig(), - } - cfg.SelectedProvider = defaultProvider.Name - cfg.CurrentModel = defaultProvider.Model + providers := config.DefaultProviders() + + cfg.Providers = providers + cfg.SelectedProvider = providers[0].Name + cfg.CurrentModel = providers[0].Model + return cfg } diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go index c047c335..67496213 100644 --- a/internal/provider/builtin/builtin_test.go +++ b/internal/provider/builtin/builtin_test.go @@ -3,9 +3,8 @@ package builtin import ( "testing" - "neo-code/internal/provider/gemini" - "neo-code/internal/provider/openai" - "neo-code/internal/provider/openll" + "neo-code/internal/config" + "neo-code/internal/provider" ) func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { @@ -15,19 +14,72 @@ func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { if len(cfg.Providers) != 3 { t.Fatalf("expected 3 builtin providers, got %d", len(cfg.Providers)) } - if cfg.Providers[0].Name != openai.Name { - t.Fatalf("expected first provider %q, got %q", openai.Name, cfg.Providers[0].Name) + if cfg.Providers[0].Name != config.OpenAIName { + t.Fatalf("expected first provider %q, got %q", config.OpenAIName, cfg.Providers[0].Name) } - if cfg.Providers[1].Name != gemini.Name { - t.Fatalf("expected second provider %q, got %q", gemini.Name, cfg.Providers[1].Name) + if cfg.Providers[1].Name != config.GeminiName { + t.Fatalf("expected second provider %q, got %q", config.GeminiName, cfg.Providers[1].Name) } - if cfg.Providers[2].Name != openll.Name { - t.Fatalf("expected third provider %q, got %q", openll.Name, cfg.Providers[2].Name) + if cfg.Providers[2].Name != config.OpenLLName { + t.Fatalf("expected third provider %q, got %q", config.OpenLLName, cfg.Providers[2].Name) } - if cfg.SelectedProvider != openai.Name { - t.Fatalf("expected selected provider %q, got %q", openai.Name, cfg.SelectedProvider) + if cfg.SelectedProvider != config.OpenAIName { + t.Fatalf("expected selected provider %q, got %q", config.OpenAIName, cfg.SelectedProvider) } - if cfg.CurrentModel != openai.DefaultModel { - t.Fatalf("expected current model %q, got %q", openai.DefaultModel, cfg.CurrentModel) + if cfg.CurrentModel != config.OpenAIDefaultModel { + t.Fatalf("expected current model %q, got %q", config.OpenAIDefaultModel, cfg.CurrentModel) + } +} + +func TestNewRegistry(t *testing.T) { + t.Parallel() + + registry, err := NewRegistry() + if err != nil { + t.Fatalf("NewRegistry() error = %v", err) + } + if registry == nil { + t.Fatal("expected non-nil registry") + } +} + +func TestRegister(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + registry := provider.NewRegistry() + err := Register(registry) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + err := Register(nil) + if err == nil { + t.Fatal("expected error for nil registry") + } + }) +} + +func TestDefaultConfigValidates(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.ApplyDefaultsFrom(*cfg) + if err := cfg.Validate(); err != nil { + t.Fatalf("expected default config to validate, got %v", err) + } +} + +func TestDefaultConfigWorkdirIsAbsolute(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.ApplyDefaultsFrom(*cfg) + if cfg.Workdir == "" { + t.Fatal("expected workdir to be set") } } diff --git a/internal/provider/gemini/gemini.go b/internal/provider/gemini/gemini.go deleted file mode 100644 index 0133e3fc..00000000 --- a/internal/provider/gemini/gemini.go +++ /dev/null @@ -1,31 +0,0 @@ -package gemini - -import ( - "neo-code/internal/config" - "neo-code/internal/provider/openai" -) - -const ( - Name = "gemini" - DriverName = openai.DriverName - DefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" - DefaultModel = "gemini-2.5-flash" - DefaultAPIKeyEnv = "GEMINI_API_KEY" -) - -var builtinModels = []string{ - DefaultModel, - "gemini-2.5-pro", - "gemini-2.0-flash", -} - -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: append([]string(nil), builtinModels...), - APIKeyEnv: DefaultAPIKeyEnv, - } -} diff --git a/internal/provider/gemini/gemini_test.go b/internal/provider/gemini/gemini_test.go deleted file mode 100644 index afab85a0..00000000 --- a/internal/provider/gemini/gemini_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package gemini - -import ( - "testing" - - "neo-code/internal/provider/openai" -) - -func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { - t.Parallel() - - cfg := BuiltinConfig() - if cfg.Name != Name { - t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) - } - if cfg.Driver != openai.DriverName { - t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) - } - if cfg.BaseURL != DefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) - } - if cfg.Model != DefaultModel { - t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) - } - if cfg.APIKeyEnv != DefaultAPIKeyEnv { - t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) - } - if len(cfg.Models) < 2 { - t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) - } -} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index 4e255880..f4056bc2 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -23,35 +23,12 @@ type Provider struct { client *http.Client } -const ( - Name = "openai" - DriverName = "openai" - DefaultBaseURL = "https://api.openai.com/v1" - DefaultModel = "gpt-4.1" - DefaultAPIKeyEnv = "OPENAI_API_KEY" -) - -var builtinModels = []string{ - DefaultModel, - "gpt-4o", - "gpt-5.4", - "gpt-5.3-codex", -} - -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: append([]string(nil), builtinModels...), - APIKeyEnv: DefaultAPIKeyEnv, - } -} +const DriverName = "openai" +// Driver 返回 OpenAI 协议驱动的定义。 func Driver() domain.DriverDefinition { return domain.DriverDefinition{ - Name: Name, + Name: DriverName, Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) { return New(cfg) }, diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 6843f704..89c16c0c 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -14,10 +14,10 @@ import ( func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { if strings.TrimSpace(baseURL) == "" { - baseURL = DefaultBaseURL + baseURL = config.OpenAIDefaultBaseURL } if strings.TrimSpace(model) == "" { - model = DefaultModel + model = config.OpenAIDefaultModel } return config.ResolvedProviderConfig{ @@ -26,7 +26,7 @@ func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig Driver: DriverName, BaseURL: baseURL, Model: model, - APIKeyEnv: DefaultAPIKeyEnv, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, }, APIKey: "test-key", } @@ -110,26 +110,8 @@ func TestMergeToolCallDeltas(t *testing.T) { } } -func TestBuiltinConfigIncludesProviderModels(t *testing.T) { - t.Parallel() - - cfg := BuiltinConfig() - if cfg.Name != DriverName { - t.Fatalf("expected provider name %q, got %q", DriverName, cfg.Name) - } - if cfg.BaseURL != DefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) - } - if len(cfg.Models) < 3 { - t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) - } - if !containsString(cfg.Models, "gpt-5.4") { - t.Fatalf("expected builtin models to include gpt-5.4, got %+v", cfg.Models) - } -} - func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { - t.Setenv(DefaultAPIKeyEnv, "test-key") + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { @@ -299,7 +281,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { } func TestProviderChatHTTPErrorResponses(t *testing.T) { - t.Setenv(DefaultAPIKeyEnv, "test-key") + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") tests := []struct { name string @@ -332,14 +314,14 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { })) defer server.Close() - provider, err := New(resolvedConfig(server.URL, DefaultModel)) + provider, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } provider.client = server.Client() _, err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: DefaultModel, + Model: config.OpenAIDefaultModel, }, make(chan domain.StreamEvent, 1)) if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) @@ -351,7 +333,7 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -386,8 +368,8 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Fatalf("buildRequest() error = %v", err) } - if payload.Model != DefaultModel { - t.Fatalf("expected default model %q, got %q", DefaultModel, payload.Model) + if payload.Model != config.OpenAIDefaultModel { + t.Fatalf("expected default model %q, got %q", config.OpenAIDefaultModel, payload.Model) } if !payload.Stream { t.Fatalf("expected stream=true") @@ -412,7 +394,7 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { func TestParseErrorAndEmitTextDelta(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -470,7 +452,7 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -689,7 +671,7 @@ func TestMergeToolCallDeltasEdgeCases(t *testing.T) { } func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { - t.Setenv(DefaultAPIKeyEnv, "test-key") + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -717,7 +699,7 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { })) defer server.Close() - provider, err := New(resolvedConfig(server.URL, DefaultModel)) + provider, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -725,7 +707,7 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { events := make(chan domain.StreamEvent, 8) _, err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: DefaultModel, + Model: config.OpenAIDefaultModel, Messages: []domain.Message{{Role: "user", Content: "edit"}}, Tools: []domain.ToolSpec{ {Name: "filesystem_edit", Description: "edit", Schema: map[string]any{"type": "object"}}, diff --git a/internal/provider/openll/openll.go b/internal/provider/openll/openll.go deleted file mode 100644 index ac99cdd9..00000000 --- a/internal/provider/openll/openll.go +++ /dev/null @@ -1,30 +0,0 @@ -package openll - -import ( - "neo-code/internal/config" -) - -const ( - Name = "openll" - DriverName = "openai" - DefaultBaseURL = "https://www.openll.top/v1" - DefaultModel = "gpt-5.4" - DefaultAPIKeyEnv = "AI_API_KEY" -) - -var builtinModels = []string{ - DefaultModel, - "gpt-5.3-codex", - "gpt-5.3-turbo", -} - -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: append([]string(nil), builtinModels...), - APIKeyEnv: DefaultAPIKeyEnv, - } -} diff --git a/internal/provider/openll/openll_test.go b/internal/provider/openll/openll_test.go deleted file mode 100644 index 379b241f..00000000 --- a/internal/provider/openll/openll_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package openll - -import ( - "testing" - - "neo-code/internal/provider/openai" -) - -func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { - t.Parallel() - - cfg := BuiltinConfig() - if cfg.Name != Name { - t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) - } - if cfg.Driver != openai.DriverName { - t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) - } - if cfg.BaseURL != DefaultBaseURL { - t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) - } - if cfg.Model != DefaultModel { - t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) - } - if cfg.APIKeyEnv != DefaultAPIKeyEnv { - t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) - } - if len(cfg.Models) < 3 { - t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) - } - if !containsString(cfg.Models, "gpt-5.4") { - t.Fatalf("expected builtin models to include gpt-5.4, got %+v", cfg.Models) - } -} - -func containsString(items []string, target string) bool { - for _, item := range items { - if item == target { - return true - } - } - return false -} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 6f209301..ad9b57d8 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -8,9 +8,7 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/builtin" - "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" - "neo-code/internal/provider/openll" ) type stubProvider struct{} @@ -62,9 +60,9 @@ func TestRegistryBuildsRegisteredDriverCaseInsensitively(t *testing.T) { ProviderConfig: config.ProviderConfig{ Name: "openai-main", Driver: "OPENAI", - BaseURL: openai.DefaultBaseURL, - Model: openai.DefaultModel, - APIKeyEnv: openai.DefaultAPIKeyEnv, + BaseURL: config.OpenAIDefaultBaseURL, + Model: config.OpenAIDefaultModel, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, }, APIKey: "test-key", }) @@ -124,9 +122,9 @@ func TestServiceListProvidersUsesConfiguredMetadata(t *testing.T) { t.Fatalf("ListProviders() error = %v", err) } expectedModels := map[string]int{ - openai.Name: len(openai.BuiltinConfig().Models), - gemini.Name: len(gemini.BuiltinConfig().Models), - openll.Name: len(openll.BuiltinConfig().Models), + config.OpenAIName: len(config.OpenAIProvider().Models), + config.GeminiName: len(config.GeminiProvider().Models), + config.OpenLLName: len(config.OpenLLProvider().Models), } if len(items) != len(expectedModels) { t.Fatalf("expected only supported providers, got %d", len(items)) @@ -294,9 +292,9 @@ func TestServiceBuildDelegatesToRegistry(t *testing.T) { ProviderConfig: config.ProviderConfig{ Name: "openai-main", Driver: "openai", - BaseURL: openai.DefaultBaseURL, - Model: openai.DefaultModel, - APIKeyEnv: openai.DefaultAPIKeyEnv, + BaseURL: config.OpenAIDefaultBaseURL, + Model: config.OpenAIDefaultModel, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, }, APIKey: "test-key", } diff --git a/internal/provider/service.go b/internal/provider/service.go index 8c2f983c..acbac2db 100644 --- a/internal/provider/service.go +++ b/internal/provider/service.go @@ -110,7 +110,7 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return err } - if !containsModel(selected.SupportedModels(), modelID) { + if !config.ContainsModelID(selected.SupportedModels(), modelID) { return ErrModelNotFound } @@ -125,16 +125,6 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return selection, nil } -func containsModel(models []string, modelID string) bool { - target := normalizeKey(modelID) - for _, model := range models { - if normalizeKey(model) == target { - return true - } - } - return false -} - func selectionFromConfig(cfg config.Config) ProviderSelection { return ProviderSelection{ ProviderID: cfg.SelectedProvider, @@ -143,7 +133,7 @@ func selectionFromConfig(cfg config.Config) ProviderSelection { } func selectModel(currentModel string, models []string, fallback string) string { - if containsModel(models, currentModel) { + if config.ContainsModelID(models, currentModel) { return strings.TrimSpace(currentModel) } return strings.TrimSpace(fallback) diff --git a/internal/provider/service_test.go b/internal/provider/service_test.go new file mode 100644 index 00000000..248dd31f --- /dev/null +++ b/internal/provider/service_test.go @@ -0,0 +1,472 @@ +package provider + +import ( + "context" + "testing" + + "neo-code/internal/config" +) + +func TestNewService(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + registry := NewRegistry() + service := NewService(manager, registry) + if service == nil { + t.Fatal("expected non-nil service") + } + }) + + t.Run("nil manager", func(t *testing.T) { + t.Parallel() + registry := NewRegistry() + service := NewService(nil, registry) + if service == nil { + t.Fatal("expected non-nil service even with nil manager") + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + service := NewService(manager, nil) + if service == nil { + t.Fatal("expected non-nil service even with nil registry") + } + }) +} + +func TestServiceListProviders(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + registry := newServiceTestRegistry(t) + service := NewService(manager, registry) + + items, err := service.ListProviders(context.Background()) + if err != nil { + t.Fatalf("ListProviders() error = %v", err) + } + if len(items) == 0 { + t.Fatal("expected at least one provider") + } + if items[0].ID != testProviderName { + t.Fatalf("expected first provider ID %q, got %q", testProviderName, items[0].ID) + } + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.ListProviders(context.Background()) + if err == nil { + t.Fatal("expected error for nil service") + } + }) + + t.Run("nil manager", func(t *testing.T) { + t.Parallel() + service := &Service{} + _, err := service.ListProviders(context.Background()) + if err == nil { + t.Fatal("expected error for nil manager") + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + service := &Service{manager: manager} + _, err := service.ListProviders(context.Background()) + if err == nil { + t.Fatal("expected error for nil registry") + } + }) + + t.Run("canceled context", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := service.ListProviders(ctx) + if err == nil { + t.Fatal("expected error for canceled context") + } + }) +} + +func TestServiceSelectProvider(t *testing.T) { + t.Run("success", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + registry := newServiceTestRegistry(t) + service := NewService(manager, registry) + + selection, err := service.SelectProvider(context.Background(), testProviderName) + if err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if selection.ProviderID != testProviderName { + t.Fatalf("expected provider ID %q, got %q", testProviderName, selection.ProviderID) + } + }) + + t.Run("provider not found", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + _, err := service.SelectProvider(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent provider") + } + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.SelectProvider(context.Background(), testProviderName) + if err == nil { + t.Fatal("expected error for nil service") + } + }) +} + +func TestServiceListModels(t *testing.T) { + t.Run("success", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + registry := NewRegistry() + service := NewService(manager, registry) + + models, err := service.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models) == 0 { + t.Fatal("expected at least one model") + } + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.ListModels(context.Background()) + if err == nil { + t.Fatal("expected error for nil service") + } + }) + + t.Run("canceled context", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := service.ListModels(ctx) + if err == nil { + t.Fatal("expected error for canceled context") + } + }) +} + +func TestServiceSetCurrentModel(t *testing.T) { + t.Run("success", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + registry := NewRegistry() + service := NewService(manager, registry) + + newModel := "gpt-4o" + selection, err := service.SetCurrentModel(context.Background(), newModel) + if err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + if selection.ModelID != newModel { + t.Fatalf("expected model ID %q, got %q", newModel, selection.ModelID) + } + }) + + t.Run("empty model ID", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + _, err := service.SetCurrentModel(context.Background(), "") + if err == nil { + t.Fatal("expected error for empty model ID") + } + }) + + t.Run("model not found", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + registry := NewRegistry() + service := NewService(manager, registry) + + _, err := service.SetCurrentModel(context.Background(), "nonexistent-model") + if err == nil { + t.Fatal("expected error for nonexistent model") + } + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.SetCurrentModel(context.Background(), "model") + if err == nil { + t.Fatal("expected error for nil service") + } + }) +} + +func TestServiceBuild(t *testing.T) { + t.Parallel() + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) + if err == nil { + t.Fatal("expected error for nil service") + } + }) + + t.Run("nil manager", func(t *testing.T) { + t.Parallel() + service := &Service{} + _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) + if err == nil { + t.Fatal("expected error for nil manager") + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + service := &Service{manager: manager} + _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) + if err == nil { + t.Fatal("expected error for nil registry") + } + }) +} + +func TestServiceValidate(t *testing.T) { + t.Parallel() + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + var service *Service + err := service.validate() + if err == nil { + t.Fatal("expected error for nil service") + } + }) + + t.Run("nil manager", func(t *testing.T) { + t.Parallel() + service := &Service{} + err := service.validate() + if err == nil { + t.Fatal("expected error for nil manager") + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + service := &Service{manager: manager} + err := service.validate() + if err == nil { + t.Fatal("expected error for nil registry") + } + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) + registry := NewRegistry() + service := NewService(manager, registry) + err := service.validate() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} + +func TestSelectModelHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + currentModel string + models []string + fallback string + expected string + }{ + { + name: "current model in list", + currentModel: "gpt-4o", + models: []string{"gpt-4.1", "gpt-4o", "gpt-5.4"}, + fallback: "gpt-4.1", + expected: "gpt-4o", + }, + { + name: "current model not in list", + currentModel: "unknown-model", + models: []string{"gpt-4.1", "gpt-4o", "gpt-5.4"}, + fallback: "gpt-4.1", + expected: "gpt-4.1", + }, + { + name: "empty current model", + currentModel: "", + models: []string{"gpt-4.1", "gpt-4o", "gpt-5.4"}, + fallback: "gpt-4.1", + expected: "gpt-4.1", + }, + { + name: "current model with whitespace", + currentModel: " gpt-4o ", + models: []string{"gpt-4.1", "gpt-4o", "gpt-5.4"}, + fallback: "gpt-4.1", + expected: "gpt-4o", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := selectModel(tt.currentModel, tt.models, tt.fallback) + if result != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestModelDescriptorsHelper(t *testing.T) { + t.Parallel() + + t.Run("empty models", func(t *testing.T) { + t.Parallel() + result := modelDescriptors(nil) + if result != nil { + t.Fatalf("expected nil for empty models, got %v", result) + } + }) + + t.Run("non-empty models", func(t *testing.T) { + t.Parallel() + models := []string{"gpt-4.1", "gpt-4o", "gpt-5.4"} + result := modelDescriptors(models) + if len(result) != 3 { + t.Fatalf("expected 3 descriptors, got %d", len(result)) + } + for i, desc := range result { + if desc.ID != models[i] { + t.Fatalf("expected ID %q, got %q", models[i], desc.ID) + } + if desc.Name != models[i] { + t.Fatalf("expected name %q, got %q", models[i], desc.Name) + } + } + }) + + t.Run("models with empty strings", func(t *testing.T) { + t.Parallel() + models := []string{"gpt-4.1", "", "gpt-4o", " ", "gpt-5.4"} + result := modelDescriptors(models) + if len(result) != 3 { + t.Fatalf("expected 3 descriptors after filtering empty strings, got %d", len(result)) + } + }) +} + +func testDefaultConfig() *config.Config { + cfg := config.Default() + providers := config.DefaultProviders() + + cfg.Providers = providers + cfg.SelectedProvider = providers[0].Name + cfg.CurrentModel = providers[0].Model + + return cfg +} + +func newServiceTestRegistry(t *testing.T) *Registry { + t.Helper() + + registry := NewRegistry() + if err := registry.Register(DriverDefinition{ + Name: testProviderName, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) { + return serviceTestProvider{}, nil + }, + }); err != nil { + t.Fatalf("register test driver: %v", err) + } + return registry +} + +type serviceTestProvider struct{} + +func (serviceTestProvider) Chat(ctx context.Context, req ChatRequest, events chan<- StreamEvent) (ChatResponse, error) { + return ChatResponse{}, nil +} + +const ( + testProviderName = "openai" + testAPIKeyEnv = "OPENAI_API_KEY" +) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 193e7295..d5ebeab6 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -120,7 +120,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { go s.forwardProviderEvents(ctx, input.RunID, session.ID, streamEvents, streamDone) resp, err := modelProvider.Chat(ctx, provider.ChatRequest{ - Model: cfg.CurrentModel, + Model: resolvedProvider.Model, SystemPrompt: s.systemPrompt(), Messages: s.trimMessages(session.Messages), Tools: s.toolRegistry.GetSpecs(), diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index a9f581fc..254a254c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -11,7 +11,6 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/builtin" - "neo-code/internal/provider/openai" "neo-code/internal/tools" ) @@ -1003,8 +1002,8 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { func newRuntimeConfigManager(t *testing.T) *config.Manager { t.Helper() - restoreRuntimeEnv(t, openai.DefaultAPIKeyEnv) - if err := os.Setenv(openai.DefaultAPIKeyEnv, "test-key"); err != nil { + restoreRuntimeEnv(t, config.OpenAIDefaultAPIKeyEnv) + if err := os.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key"); err != nil { t.Fatalf("set env: %v", err) } manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 5a62ad72..9ffea7ba 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -14,7 +14,6 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/builtin" - provideropenai "neo-code/internal/provider/openai" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" ) @@ -317,7 +316,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { app.openModelPicker() app.closePicker() - app.selectCurrentModel(provideropenai.DefaultModel) + app.selectCurrentModel(config.OpenAIDefaultModel) app.appendAssistantChunk("hello") app.appendAssistantChunk(" world") if !app.lastAssistantMatches("hello world") { @@ -498,7 +497,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { } manager := newTestConfigManager(t) - msg := runModelSelection(newTestProviderService(t, manager), provideropenai.DefaultModel)() + msg := runModelSelection(newTestProviderService(t, manager), config.OpenAIDefaultModel)() if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } @@ -808,11 +807,11 @@ func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.Providers = append(cfg.Providers, config.ProviderConfig{ Name: "openai-alt", - Driver: provideropenai.DriverName, - BaseURL: provideropenai.DefaultBaseURL, + Driver: "openai", + BaseURL: config.OpenAIDefaultBaseURL, Model: "gpt-4o", Models: []string{"gpt-4o"}, - APIKeyEnv: provideropenai.DefaultAPIKeyEnv, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, }) cfg.CurrentModel = "unsupported-current" return nil From 2fa603bc0cfbc4de5c2ace611a2c42e76dafbeca Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 31 Mar 2026 13:54:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(guides):=E9=87=8D=E6=96=B0=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=B8=A4=E4=BB=BDprovider=E6=8C=87=E5=AF=BC=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/adding-providers.md | 270 ++++++++++++++++++-------- docs/guides/configuration.md | 330 +++++++++++++++++++++++++++----- 2 files changed, 469 insertions(+), 131 deletions(-) diff --git a/docs/guides/adding-providers.md b/docs/guides/adding-providers.md index bd9d0a88..03fe7441 100644 --- a/docs/guides/adding-providers.md +++ b/docs/guides/adding-providers.md @@ -4,121 +4,143 @@ ## 架构概览 -NeoCode 的 provider 分为两层: +NeoCode 的 provider 架构采用**集中式配置 + 驱动复用**的设计: -- **配置层**(`ProviderConfig`):提供 `base_url`、模型列表、API Key 环境变量等元数据 -- **驱动层**(`DriverDefinition`):负责实际的 API 协议差异 +### 两层分离 -一个驱动可被多个 provider 复用(如 `gemini` 和 `openll` 都复用 `openai` 驱动)。 +- **配置层**(`internal/config/builtin_providers.go`):集中管理所有 provider 的元数据 + - Provider 名称、Driver 名称 + - Base URL、默认模型、模型列表 + - API Key 环境变量名 -## 方式一:复用已有驱动(推荐) +- **驱动层**(`internal/provider/openai/` 等):负责实际的 API 协议实现 + - 请求构造、响应解析 + - 流式输出、Tool Call 处理 -适用于 OpenAI 兼容接口。只需添加配置文件,无需编写新的驱动代码。 +### 驱动复用 -### 示例:添加 DeepSeek - -**1. 创建 `internal/provider/deepseek/deepseek.go`:** +一个驱动可被多个 provider 复用。当前所有内置 provider 都复用 `openai` 驱动: ```go -package deepseek +// internal/config/builtin_providers.go +OpenAIProvider() // Driver: "openai" +GeminiProvider() // Driver: "openai" (OpenAI-compatible API) +OpenLLProvider() // Driver: "openai" (OpenAI-compatible API) +``` -import ( - "neo-code/internal/config" -) +## 方式一:添加 OpenAI 兼容 Provider(推荐) + +适用于 OpenAI 兼容接口(大多数第三方服务)。只需在配置层添加,无需编写新驱动。 + +### 步骤:添加 DeepSeek +**1. 在 `internal/config/builtin_providers.go` 添加常量和配置:** + +```go const ( - Name = "deepseek" - DriverName = "openai" // 复用 openai 驱动 - DefaultBaseURL = "https://api.deepseek.com/v1" - DefaultModel = "deepseek-chat" - DefaultAPIKeyEnv = "DEEPSEEK_API_KEY" + // ... 现有常量 ... + + DeepSeekName = "deepseek" + DeepSeekDefaultBaseURL = "https://api.deepseek.com/v1" + DeepSeekDefaultModel = "deepseek-chat" + DeepSeekDefaultAPIKeyEnv = "DEEPSEEK_API_KEY" ) -var builtinModels = []string{ - DefaultModel, +var deepSeekModels = []string{ + DeepSeekDefaultModel, "deepseek-coder", } -// BuiltinConfig 返回该 provider 的内建配置。 -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: append([]string(nil), builtinModels...), - APIKeyEnv: DefaultAPIKeyEnv, +// DeepSeekProvider 返回 DeepSeek provider 的默认配置。 +func DeepSeekProvider() ProviderConfig { + return ProviderConfig{ + Name: DeepSeekName, + Driver: "openai", // 复用 openai 驱动 + BaseURL: DeepSeekDefaultBaseURL, + Model: DeepSeekDefaultModel, + Models: append([]string(nil), deepSeekModels...), + APIKeyEnv: DeepSeekDefaultAPIKeyEnv, } } ``` -**2. 在 `internal/provider/builtin/builtin.go` 中添加该 provider:** +**2. 在 `DefaultProviders()` 中注册:** ```go -import "neo-code/internal/provider/deepseek" - -func DefaultConfig() *config.Config { - cfg := config.Default() - defaultProvider := openai.BuiltinConfig() - cfg.Providers = []config.ProviderConfig{ - defaultProvider, - gemini.BuiltinConfig(), - openll.BuiltinConfig(), - deepseek.BuiltinConfig(), // 新增 +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), + GeminiProvider(), + OpenLLProvider(), + DeepSeekProvider(), // 新增 } - // ... } ``` +**3. 设置环境变量并测试:** + +```bash +export DEEPSEEK_API_KEY="your-api-key" +go run ./cmd/neocode +``` + +### 优点 + +- ✅ 无需编写新代码,只需配置 +- ✅ 自动继承 openai 驱动的所有功能(流式、Tool Call) +- ✅ 配置集中管理,易于维护 +- ✅ 用户无需修改 YAML 文件 + ## 方式二:实现新驱动 适用于协议不兼容的厂商(如 Anthropic、Google 原生 API)。 -### 示例:添加 Anthropic +### 步骤:添加 Anthropic -**1. 在 `internal/provider/anthropic/anthropic.go` 中实现核心类型:** +**1. 在 `internal/provider/anthropic/anthropic.go` 实现驱动:** ```go package anthropic import ( "context" + "net/http" + "neo-code/internal/config" domain "neo-code/internal/provider" ) const ( - Name = "anthropic" - DriverName = "anthropic" - DefaultBaseURL = "https://api.anthropic.com/v1" - DefaultModel = "claude-sonnet-4-20250514" - DefaultAPIKeyEnv = "ANTHROPIC_API_KEY" + Name = "anthropic" + DefaultBaseURL = "https://api.anthropic.com/v1" ) +// Provider 实现 domain.Provider 接口 type Provider struct { cfg config.ResolvedProviderConfig client *http.Client } -// New 构造函数,接收已解析的配置。 +// New 构造函数 func New(cfg config.ResolvedProviderConfig) (*Provider, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("anthropic provider: %w", err) } - // ... - return &Provider{cfg: cfg, client: &http.Client{}}, nil + return &Provider{ + cfg: cfg, + client: &http.Client{Timeout: 60 * time.Second}, + }, nil } -// Chat 实现 domain.Provider 接口。 -// 必须支持流式输出(通过 events channel)和 tool calls。 +// Chat 实现流式对话接口 func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { - // 1. 将 domain.ChatRequest 转换为厂商特定的请求格式 - // 2. 调用厂商 API(流式 SSE) + // 1. 将 domain.ChatRequest 转换为 Anthropic API 格式 + // 2. 调用 Anthropic API(流式 SSE) // 3. 解析响应,推送 StreamEventTextDelta / StreamEventToolCallStart // 4. 返回 domain.ChatResponse } -// Driver 返回驱动定义,供 Registry 注册使用。 +// Driver 返回驱动定义 func Driver() domain.DriverDefinition { return domain.DriverDefinition{ Name: Name, @@ -127,24 +149,15 @@ func Driver() domain.DriverDefinition { }, } } - -// BuiltinConfig 返回内建配置。 -func BuiltinConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - Models: []string{DefaultModel, "claude-opus-4-20250514"}, - APIKeyEnv: DefaultAPIKeyEnv, - } -} ``` -**2. 在 `internal/provider/builtin/builtin.go` 中注册驱动:** +**2. 在 `internal/provider/builtin/builtin.go` 注册驱动:** ```go -import "neo-code/internal/provider/anthropic" +import ( + "neo-code/internal/provider/anthropic" + "neo-code/internal/provider/openai" +) func Register(registry *provider.Registry) error { if registry == nil { @@ -153,33 +166,120 @@ func Register(registry *provider.Registry) error { if err := registry.Register(openai.Driver()); err != nil { return err } - return registry.Register(anthropic.Driver()) // 新增驱动注册 + return registry.Register(anthropic.Driver()) // 新增 +} +``` + +**3. 在 `internal/config/builtin_providers.go` 添加配置:** + +```go +const ( + // ... 现有常量 ... + + AnthropicName = "anthropic" + AnthropicDefaultBaseURL = "https://api.anthropic.com/v1" + AnthropicDefaultModel = "claude-sonnet-4-20250514" + AnthropicDefaultAPIKeyEnv = "ANTHROPIC_API_KEY" +) + +var anthropicModels = []string{ + AnthropicDefaultModel, + "claude-opus-4-20250514", + "claude-3-5-sonnet-20241022", +} + +func AnthropicProvider() ProviderConfig { + return ProviderConfig{ + Name: AnthropicName, + Driver: "anthropic", // 使用新的 anthropic 驱动 + BaseURL: AnthropicDefaultBaseURL, + Model: AnthropicDefaultModel, + Models: append([]string(nil), anthropicModels...), + APIKeyEnv: AnthropicDefaultAPIKeyEnv, + } +} + +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), + GeminiProvider(), + OpenLLProvider(), + AnthropicProvider(), // 新增 + } } ``` ## 关键接口与类型 +### 核心接口 + | 类型 | 位置 | 说明 | |------|------|------| -| `Provider` 接口 | `internal/provider/provider.go` | 核心接口,定义 `Chat` 方法 | -| `ChatRequest` | `internal/provider/types.go` | 请求结构:`Model`、`SystemPrompt`、`Messages`、`Tools` | -| `ChatResponse` | `internal/provider/types.go` | 响应结构:`Message`、`FinishReason`、`Usage` | -| `StreamEvent` | `internal/provider/provider.go` | 流式事件:`text_delta` 和 `tool_call_start` | -| `ProviderConfig` | `internal/config/model.go` | 配置层:`Name`、`Driver`、`BaseURL`、`Model`、`Models`、`APIKeyEnv` | -| `DriverDefinition` | `internal/provider/registry.go` | 驱动层:`Name` + `Build` 构造函数 | +| `Provider` | `internal/provider/types.go` | 核心接口,定义 `Chat` 方法 | +| `DriverDefinition` | `internal/provider/registry.go` | 驱动定义:`Name` + `Build` 构造函数 | | `Registry` | `internal/provider/registry.go` | 驱动注册中心 | +### 数据结构 + +| 类型 | 位置 | 说明 | +|------|------|------| +| `ChatRequest` | `internal/provider/types.go` | 请求:`Model`、`SystemPrompt`、`Messages`、`Tools` | +| `ChatResponse` | `internal/provider/types.go` | 响应:`Message`、`FinishReason`、`Usage` | +| `StreamEvent` | `internal/provider/types.go` | 流式事件:`TextDelta`、`ToolCallStart` | +| `ProviderConfig` | `internal/config/model.go` | 配置:`Name`、`Driver`、`BaseURL`、`Model`、`Models`、`APIKeyEnv` | + ## 设计约束 -**必须遵守**: +### 必须遵守 + +✅ **配置集中管理** +- 所有内置 provider 配置统一在 `internal/config/builtin_providers.go` +- 不再为每个 provider 创建独立的包 + +✅ **API Key 安全** +- 只从环境变量读取,不写入 `config.yaml` +- 不硬编码在源码中 + +✅ **驱动职责清晰** +- 驱动只负责协议构造与响应解析 +- 不持有模型列表、base_url 等配置信息 + +✅ **架构分层** +- 厂商差异收敛在 `internal/provider/` 内 +- `runtime`、`tui` 等上层模块只依赖统一的 `Provider` 接口 +- `base_url` 不在 TUI 中展示给用户 + +### 最佳实践 + +1. **优先复用现有驱动** + - 大多数 OpenAI 兼容服务无需编写新驱动 + - 只需在配置层添加即可 + +2. **配置即代码** + - provider 配置随代码版本发布 + - 用户无需手动配置 providers 列表 + +3. **测试覆盖** + - 新驱动必须添加完整的单元测试 + - 使用 `httptest.NewServer` 模拟 HTTP 调用 + - 不使用真实 API Key + +## 示例:当前内置 Provider + +```go +// internal/config/builtin_providers.go + +func DefaultProviders() []ProviderConfig { + return []ProviderConfig{ + OpenAIProvider(), // OpenAI 官方 API + GeminiProvider(), // Google Gemini (OpenAI-compatible) + OpenLLProvider(), // OpenLL 服务 (OpenAI-compatible) + } +} +``` -- **API Key 只从环境变量读取**,不写入 `config.yaml`,不硬编码在源码中 -- **驱动层不持有模型列表**,`Models` 完全由配置层(`ProviderConfig.Models`)控制 -- **厂商差异收敛在 `internal/provider/` 内**,`runtime`、`tui` 等上层模块只依赖统一的 `Provider` 接口 -- **`base_url` 不向用户暴露**,用户在 TUI 中只能看到 provider 名称和模型列表 +所有内置 provider 都复用 `openai` 驱动,配置集中在 `builtin_providers.go`。 ## 相关文档 -- [Provider Interface Target Design](../provider-interface-target-design.md) -- [Provider Module Interface](../provider-module-interface.md) -- [Provider Schema Strategy](../provider-schema-strategy.md) +- [Provider Architecture Optimization PR](../provider-architecture-optimization-pr.md) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 01a4d1e0..bc2a834f 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -4,26 +4,42 @@ ## Provider 策略 -NeoCode 采用"内建 Provider 优先"的策略: +NeoCode 采用"内置 Provider 优先"的策略: -- 内建 provider 定义随代码版本发布 +### 核心原则 + +✅ **配置集中管理** +- 所有内置 provider 定义集中在 `internal/config/builtin_providers.go` +- 配置随代码版本发布,自动更新 + +✅ **最小持久化** - `config.yaml` 不再持久化完整 `providers` 列表 -- `config.yaml` 只保存当前选择状态和通用运行配置 -- 运行时的 `providers` 完全来自代码内建定义 -- API Key 只从环境变量读取,不写入 YAML -- 当前内建 provider 包括 `openai` 和 `gemini` -- `gemini` 复用 OpenAI-compatible driver,请求地址指向 Gemini 的兼容接口 -- provider 实例自己定义 `base_url`、默认模型、可选模型列表和 `api_key_env` -- `base_url` 不在 TUI 中展示给用户 -- driver 只负责协议构造与响应解析,不决定 `models`、`base_url` 或 `api_key_env` +- 只保存当前选择状态和通用运行配置 +- 运行时的 `providers` 完全来自代码内置定义 + +✅ **安全第一** +- API Key 只从环境变量读取,永不写入 YAML +- 不硬编码在源码中 +- 支持通过 `.env` 文件管理 + +### 当前内置 Provider + +| Provider | Driver | 说明 | +|----------|--------|------| +| `openai` | `openai` | OpenAI 官方 API | +| `gemini` | `openai` | Google Gemini (OpenAI-compatible API) | +| `openll` | `openai` | OpenLL 服务 (OpenAI-compatible API) | + +所有内置 provider 都复用 `openai` 驱动,支持流式输出和 Tool Call。 -### 设计理念 +### 设计优势 -这意味着: +这种方式意味着: -- 新用户启动后会自动拿到当前版本最新的内建 provider -- 未来代码新增 provider 时,新用户不需要修改 YAML -- 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理为新的最小状态格式 +- ✅ 新用户启动后自动获得当前版本最新的内置 provider +- ✅ 未来代码新增 provider 时,用户无需修改 YAML +- ✅ 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理 +- ✅ 配置文件始终保持简洁,只包含必要的运行时状态 ## 配置文件 @@ -33,57 +49,279 @@ NeoCode 采用"内建 Provider 优先"的策略: ~/.neocode/config.yaml ``` -### 配置结构示例 +### 完整配置示例 ```yaml selected_provider: openai -current_model: gpt-5.4 -workdir: . -shell: powershell +current_model: gpt-4.1 +workdir: /Users/username/projects/myproject +shell: bash max_loops: 8 -tool_timeout_sec: 20 + +tools: + webfetch: + max_response_bytes: 1048576 + supported_content_types: + - text/html + - text/plain + - application/json ``` ### 字段说明 -| 字段 | 类型 | 说明 | -|------|------|------| -| `selected_provider` | string | 当前选择的 provider 名称 | -| `current_model` | string | 当前选择的模型名称 | -| `workdir` | string | 工作目录路径 | -| `shell` | string | Shell 类型(如 `powershell`、`bash`) | -| `max_loops` | int | Agent 推理循环最大轮数 | -| `tool_timeout_sec` | int | 工具执行超时时间(秒) | +#### 基础配置 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `selected_provider` | string | `openai` | 当前选择的 provider 名称 | +| `current_model` | string | 取决于 provider | 当前选择的模型名称 | +| `workdir` | string | `.` (当前目录) | 工作目录的绝对路径 | +| `shell` | string | `bash` (Linux/Mac)
`powershell` (Windows) | Shell 类型 | +| `max_loops` | int | `8` | Agent 推理循环最大轮数 | + +#### 工具配置 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `tools.webfetch.max_response_bytes` | int | `1048576` (1MB) | WebFetch 工具最大响应字节数 | +| `tools.webfetch.supported_content_types` | []string | `[text/html, text/plain, application/json]` | 支持的内容类型 | + +### 配置文件特点 -### 环境变量 +**自动管理**: +- 首次启动时自动创建默认配置 +- `workdir` 自动转换为绝对路径 +- 无效配置会在启动时报错 -每个 provider 对应的环境变量: +**最小持久化**: +- 不保存 `providers` 列表(由代码内置提供) +- 不保存 `base_url`、`models` 等 provider 元数据 +- 只保存用户的选择状态和自定义设置 -| Provider | 环境变量 | 说明 | -|----------|----------|------| -| `openai` | `OPENAI_API_KEY` | OpenAI API Key | -| `gemini` | `GEMINI_API_KEY` | Google Gemini API Key | +## 环境变量 -**安全提示**: -- API Key 仅从环境变量读取,永不写入配置文件 -- 不要将 API Key 硬编码在代码中 -- 建议使用 `.env` 文件或系统环境变量管理密钥 +每个 provider 对应的 API Key 环境变量: + +| Provider | 环境变量 | 获取方式 | +|----------|----------|----------| +| `openai` | `OPENAI_API_KEY` | [OpenAI Platform](https://platform.openai.com/api-keys) | +| `gemini` | `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/apikey) | +| `openll` | `AI_API_KEY` | OpenLL 服务提供商 | + +### 环境变量管理 + +#### 方式一:系统环境变量 + +**Linux/macOS**: +```bash +export OPENAI_API_KEY="sk-..." +export GEMINI_API_KEY="AI..." +export AI_API_KEY="your-key" +``` + +**Windows PowerShell**: +```powershell +$env:OPENAI_API_KEY="sk-..." +$env:GEMINI_API_KEY="AI..." +$env:AI_API_KEY="your-key" +``` + +#### 方式二:.env 文件 + +**项目根目录 `.env`**: +```env +OPENAI_API_KEY=sk-... +GEMINI_API_KEY=AI... +AI_API_KEY=your-key +``` + +**NeoCode 管理目录 `~/.neocode/.env`**: +```env +OPENAI_API_KEY=sk-... +GEMINI_API_KEY=AI... +``` + +NeoCode 会自动加载这两个位置的 `.env` 文件。 + +### 安全最佳实践 + +⚠️ **重要安全提示**: + +1. **永不提交 API Key** + - 确保 `.env` 文件在 `.gitignore` 中 + - 不要将包含 API Key 的文件提交到版本控制 + +2. **使用环境变量** + - API Key 仅从环境变量读取 + - 永不写入配置文件 + - 不硬编码在代码中 + +3. **密钥轮换** + - 定期更换 API Key + - 不要在多个环境使用同一个 Key ## Slash Commands NeoCode 提供以下 slash 命令用于快速切换配置: -- `/provider` — 打开 provider 选择器 -- `/model` — 打开当前 provider 的模型选择器 +### /provider - Provider 选择器 + +``` +/provider +``` + +打开 provider 选择器,列出所有可用的内置 provider。 + +**界面示例**: +``` +? Select a provider: + ❯ openai + gemini + openll +``` + +### /model - 模型选择器 + +``` +/model +``` + +打开当前 provider 的模型选择器。 + +**界面示例**(选择 openai provider 后): +``` +? Select a model: + ❯ gpt-4.1 + gpt-4o + gpt-5.4 + gpt-5.3-codex +``` ## 配置管理 配置管理由 `internal/config` 模块负责: -- YAML 加载与保存 -- `.env` 文件集成 -- 默认值管理 -- 并发安全访问 -- 配置校验 +### 核心功能 + +- ✅ YAML 加载与保存 +- ✅ `.env` 文件自动加载 +- ✅ 默认值管理 +- ✅ 并发安全访问 +- ✅ 配置校验 + +### 配置流程 + +``` +启动 + ↓ +加载 ~/.neocode/config.yaml + ↓ +应用内置 defaults (来自 builtin_providers.go) + ↓ +合并 .env 文件环境变量 + ↓ +验证配置完整性 + ↓ +运行时使用 +``` + +### 配置更新 + +当用户通过 TUI 切换 provider 或 model 时: + +1. 更新内存中的配置 +2. 立即持久化到 `config.yaml` +3. 下次启动自动恢复选择状态 + +## 添加自定义 Provider + +如需添加自定义 provider(如企业内部服务),请参考: + +👉 [adding-providers.md](./adding-providers.md) + +### 快速步骤 + +**OpenAI 兼容服务**(推荐): +1. 在 `internal/config/builtin_providers.go` 添加配置函数 +2. 在 `DefaultProviders()` 中注册 +3. 设置对应的环境变量 + +**自定义协议**: +1. 在 `internal/provider/yourprovider/` 实现驱动 +2. 在 `internal/provider/builtin/builtin.go` 注册驱动 +3. 在 `internal/config/builtin_providers.go` 添加配置 + +## 配置示例场景 + +### 场景一:使用 Gemini + +```bash +# 1. 设置环境变量 +export GEMINI_API_KEY="your-gemini-api-key" + +# 2. 启动 NeoCode +go run ./cmd/neocode + +# 3. 在 TUI 中切换 +/provider # 选择 gemini +/model # 选择 gemini-2.5-flash +``` + +### 场景二:使用 OpenLL + +```bash +# 1. 设置环境变量 +export AI_API_KEY="your-openll-api-key" + +# 2. 启动并切换 +go run ./cmd/neocode +# 在 TUI 中: /provider → openll +``` + +### 场景三:自定义工作目录 + +```yaml +# ~/.neocode/config.yaml +selected_provider: openai +current_model: gpt-4.1 +workdir: /Users/username/projects/myproject +shell: bash +max_loops: 10 +``` + +## 故障排查 + +### 配置加载失败 + +**错误**:`config validation failed: providers is empty` + +**解决**: +- 确保使用最新版本的代码 +- 删除 `~/.neocode/config.yaml` 让系统重新生成 + +### API Key 未找到 + +**错误**:`environment variable OPENAI_API_KEY is empty` + +**解决**: +```bash +# 检查环境变量 +echo $OPENAI_API_KEY + +# 或检查 .env 文件 +cat ~/.neocode/.env +``` + +### Provider 不存在 + +**错误**:`provider not found: xxx` + +**解决**: +- 检查 provider 名称拼写 +- 使用 `/provider` 命令查看所有可用 provider + +## 相关文档 -详细设计参见 [`config-management-detail-design.md`](../config-management-detail-design.md)。 +- [配置管理详细设计](../config-management-detail-design.md) +- [Provider 架构优化 PR](../provider-architecture-optimization-pr.md) +- [添加新 Provider](./adding-providers.md)