From 6fc015f239c87df6cb9ffa7745c21a99834f5dc3 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 3 Apr 2026 14:33:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor=EF=BC=9A=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=BD=93=E5=89=8Dprovider=E5=88=86=E5=B1=82=EF=BC=8C=E8=AE=A9?= =?UTF-8?q?=E8=81=8C=E8=B4=A3=E6=9B=B4=E6=B8=85=E6=99=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +- internal/app/bootstrap.go | 14 +- internal/config/default_config.go | 14 + internal/config/default_config_test.go | 50 ++ internal/context/test_helpers_test.go | 4 +- internal/provider/builtin/builtin.go | 13 - internal/provider/builtin/builtin_test.go | 50 +- internal/provider/catalog/service.go | 233 ++++++++ internal/provider/catalog/service_test.go | 280 +++++++++ internal/provider/catalog/store.go | 121 ++++ internal/provider/catalog/store_test.go | 82 +++ internal/provider/errors.go | 2 - internal/provider/model_catalog_store.go | 130 ----- internal/provider/model_catalog_store_test.go | 73 --- internal/provider/registry_test.go | 266 --------- internal/provider/selection/service.go | 273 +++++++++ internal/provider/selection/service_test.go | 322 +++++++++++ internal/provider/service.go | 407 ------------- internal/provider/service_test.go | 541 ------------------ internal/runtime/runtime_test.go | 3 +- internal/tui/update_test.go | 22 +- 21 files changed, 1409 insertions(+), 1501 deletions(-) create mode 100644 internal/config/default_config.go create mode 100644 internal/config/default_config_test.go create mode 100644 internal/provider/catalog/service.go create mode 100644 internal/provider/catalog/service_test.go create mode 100644 internal/provider/catalog/store.go create mode 100644 internal/provider/catalog/store_test.go delete mode 100644 internal/provider/model_catalog_store.go delete mode 100644 internal/provider/model_catalog_store_test.go create mode 100644 internal/provider/selection/service.go create mode 100644 internal/provider/selection/service_test.go delete mode 100644 internal/provider/service.go delete mode 100644 internal/provider/service_test.go diff --git a/README.md b/README.md index 317e8c20..67044dfe 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,10 @@ go run ./cmd/neocode - **`internal/config`** — 配置管理、环境变量、YAML 加载 - **`internal/context`** — system prompt、消息裁剪与上下文构建 -- **`internal/provider`** — 内建 LLM Provider 抽象、模型元数据归一化、动态发现与 catalog 缓存 +- **`internal/provider`** — Provider 契约、驱动注册与通用领域类型 +- **`internal/provider/catalog`** — 模型发现、catalog 缓存与后台刷新 +- **`internal/provider/selection`** — provider/model 选择与配置同步 +- **`internal/provider/builtin`** — 内建 driver 注册 - **`internal/runtime`** — ReAct 主循环、事件流、会话管理 - **`internal/tools`** — 工具注册表与具体工具实现 - **`internal/tui`** — 终端 UI、交互体验、事件桥接 @@ -108,7 +111,10 @@ go run ./cmd/neocode │ ├── app # 应用装配 │ ├── config # 配置管理 │ ├── context # 上下文构建 -│ ├── provider # Provider 抽象、模型发现与元数据缓存 +│ ├── provider # Provider 契约与驱动注册 +│ │ ├── builtin # 内建 driver 注册 +│ │ ├── catalog # 模型发现与缓存 +│ │ └── selection # provider/model 选择服务 │ ├── runtime # ReAct 循环与事件流 │ ├── tools # 工具系统 │ └── tui # 终端 UI diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 1ccd1462..9fee7127 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -8,8 +8,9 @@ import ( "neo-code/internal/config" agentcontext "neo-code/internal/context" - "neo-code/internal/provider" "neo-code/internal/provider/builtin" + providercatalog "neo-code/internal/provider/catalog" + providerselection "neo-code/internal/provider/selection" agentruntime "neo-code/internal/runtime" "neo-code/internal/security" "neo-code/internal/tools" @@ -20,7 +21,7 @@ import ( ) func NewProgram(ctx context.Context) (*tea.Program, error) { - loader := config.NewLoader("", builtin.DefaultConfig()) + loader := config.NewLoader("", config.DefaultConfig()) manager := config.NewManager(loader) if _, err := manager.Load(ctx); err != nil { return nil, err @@ -30,8 +31,9 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { if err != nil { return nil, err } - providerService := provider.NewService(manager, providerRegistry, nil) - if _, err := providerService.EnsureSelection(ctx); err != nil { + modelCatalogs := providercatalog.NewService(manager.BaseDir(), providerRegistry, nil) + providerSelection := providerselection.NewService(manager, providerRegistry, modelCatalogs) + if _, err := providerSelection.EnsureSelection(ctx); err != nil { return nil, err } @@ -48,11 +50,11 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { manager, toolManager, sessionStore, - providerService, + providerRegistry, agentcontext.NewBuilder(), ) - tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerService) + tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerSelection) if err != nil { return nil, err } diff --git a/internal/config/default_config.go b/internal/config/default_config.go new file mode 100644 index 00000000..54e320ec --- /dev/null +++ b/internal/config/default_config.go @@ -0,0 +1,14 @@ +package config + +// DefaultConfig returns the application defaults with builtin provider metadata attached. +func DefaultConfig() *Config { + cfg := Default() + providers := DefaultProviders() + cfg.Providers = providers + if len(providers) == 0 { + return cfg + } + cfg.SelectedProvider = providers[0].Name + cfg.CurrentModel = providers[0].Model + return cfg +} diff --git a/internal/config/default_config_test.go b/internal/config/default_config_test.go new file mode 100644 index 00000000..061f8608 --- /dev/null +++ b/internal/config/default_config_test.go @@ -0,0 +1,50 @@ +package config + +import "testing" + +func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + if len(cfg.Providers) != 4 { + t.Fatalf("expected 4 builtin providers, got %d", len(cfg.Providers)) + } + if cfg.Providers[0].Name != OpenAIName { + t.Fatalf("expected first provider %q, got %q", OpenAIName, cfg.Providers[0].Name) + } + if cfg.Providers[1].Name != GeminiName { + t.Fatalf("expected second provider %q, got %q", GeminiName, cfg.Providers[1].Name) + } + if cfg.Providers[2].Name != OpenLLName { + t.Fatalf("expected third provider %q, got %q", OpenLLName, cfg.Providers[2].Name) + } + if cfg.Providers[3].Name != QiniuName { + t.Fatalf("expected fourth provider %q, got %q", QiniuName, cfg.Providers[3].Name) + } + if cfg.SelectedProvider != OpenAIName { + t.Fatalf("expected selected provider %q, got %q", OpenAIName, cfg.SelectedProvider) + } + if cfg.CurrentModel != OpenAIDefaultModel { + t.Fatalf("expected current model %q, got %q", OpenAIDefaultModel, cfg.CurrentModel) + } +} + +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/context/test_helpers_test.go b/internal/context/test_helpers_test.go index aeef7728..4186cb2f 100644 --- a/internal/context/test_helpers_test.go +++ b/internal/context/test_helpers_test.go @@ -1,9 +1,9 @@ package context -import "neo-code/internal/provider/builtin" +import "neo-code/internal/config" func testMetadata(workdir string) Metadata { - cfg := builtin.DefaultConfig() + cfg := config.DefaultConfig() return Metadata{ Workdir: workdir, Shell: cfg.Shell, diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index a2c128b8..0c10abe1 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -3,23 +3,10 @@ package builtin import ( "errors" - "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/openai" ) -// DefaultConfig 返回默认配置,包含所有内置 provider。 -func DefaultConfig() *config.Config { - cfg := config.Default() - providers := config.DefaultProviders() - - cfg.Providers = providers - cfg.SelectedProvider = providers[0].Name - cfg.CurrentModel = providers[0].Model - - return cfg -} - func NewRegistry() (*provider.Registry, error) { registry := provider.NewRegistry() if err := Register(registry); err != nil { diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go index f3d9242d..8d39d296 100644 --- a/internal/provider/builtin/builtin_test.go +++ b/internal/provider/builtin/builtin_test.go @@ -3,36 +3,9 @@ package builtin import ( "testing" - "neo-code/internal/config" + "neo-code/internal/provider/openai" ) -func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - if len(cfg.Providers) != 4 { - t.Fatalf("expected 4 builtin providers, got %d", len(cfg.Providers)) - } - 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 != config.GeminiName { - t.Fatalf("expected second provider %q, got %q", config.GeminiName, cfg.Providers[1].Name) - } - if cfg.Providers[2].Name != config.OpenLLName { - t.Fatalf("expected third provider %q, got %q", config.OpenLLName, cfg.Providers[2].Name) - } - if cfg.Providers[3].Name != config.QiniuName { - t.Fatalf("expected fourth provider %q, got %q", config.QiniuName, cfg.Providers[3].Name) - } - if cfg.SelectedProvider != config.OpenAIName { - t.Fatalf("expected selected provider %q, got %q", config.OpenAIName, cfg.SelectedProvider) - } - if cfg.CurrentModel != config.OpenAIDefaultModel { - t.Fatalf("expected current model %q, got %q", config.OpenAIDefaultModel, cfg.CurrentModel) - } -} - func TestNewRegistry(t *testing.T) { t.Parallel() @@ -43,24 +16,7 @@ func TestNewRegistry(t *testing.T) { if registry == nil { t.Fatal("expected non-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") + if !registry.Supports(openai.DriverName) { + t.Fatalf("expected registry to include %q driver", openai.DriverName) } } diff --git a/internal/provider/catalog/service.go b/internal/provider/catalog/service.go new file mode 100644 index 00000000..730ba9f1 --- /dev/null +++ b/internal/provider/catalog/service.go @@ -0,0 +1,233 @@ +package catalog + +import ( + "context" + "errors" + "strings" + "sync" + "time" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +const ( + defaultTTL = 24 * time.Hour + defaultBackgroundTimeout = 30 * time.Second +) + +type Service struct { + registry *provider.Registry + store Store + catalogTTL time.Duration + backgroundTimeout time.Duration + now func() time.Time + + refreshMu sync.Mutex + inFlightByID map[string]struct{} +} + +func NewService(baseDir string, registry *provider.Registry, store Store) *Service { + if store == nil && strings.TrimSpace(baseDir) != "" { + store = NewJSONStore(baseDir) + } + + return &Service{ + registry: registry, + store: store, + catalogTTL: defaultTTL, + backgroundTimeout: defaultBackgroundTimeout, + now: time.Now, + inFlightByID: map[string]struct{}{}, + } +} + +func (s *Service) ListProviderModels(ctx context.Context, providerCfg config.ProviderConfig) ([]provider.ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + return s.modelsForProvider(ctx, providerCfg, queryOptions{ + allowSyncRefresh: true, + queueRefresh: true, + }), nil +} + +func (s *Service) ListProviderModelsSnapshot(ctx context.Context, providerCfg config.ProviderConfig) ([]provider.ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + return s.modelsForProvider(ctx, providerCfg, queryOptions{ + queueRefresh: true, + }), nil +} + +func (s *Service) ListProviderModelsCached(ctx context.Context, providerCfg config.ProviderConfig) ([]provider.ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + return s.modelsForProvider(ctx, providerCfg, queryOptions{}), nil +} + +func (s *Service) validate() error { + if s == nil { + return errors.New("provider catalog: service is nil") + } + if s.registry == nil { + return errors.New("provider catalog: registry is nil") + } + return nil +} + +type queryOptions struct { + allowSyncRefresh bool + queueRefresh bool +} + +func (s *Service) modelsForProvider(ctx context.Context, providerCfg config.ProviderConfig, options queryOptions) []provider.ModelDescriptor { + defaultModels := descriptorsFromIDs([]string{providerCfg.Model}) + + cached, cachedOK := s.loadCatalogModels(ctx, providerCfg) + if !cachedOK && options.allowSyncRefresh { + discovered, ok := s.discoverAndPersist(ctx, providerCfg) + if ok { + cached = discovered + cachedOK = true + } + } + + if options.queueRefresh && (!cachedOK || s.catalogExpired(ctx, providerCfg)) { + s.queueRefresh(providerCfg) + } + + return provider.MergeModelDescriptors(cached, defaultModels) +} + +func (s *Service) catalogExpired(ctx context.Context, providerCfg config.ProviderConfig) bool { + modelCatalog, err := s.loadCatalog(ctx, providerCfg) + if err != nil { + return false + } + return modelCatalog.Expired(s.now()) +} + +func (s *Service) loadCatalogModels(ctx context.Context, providerCfg config.ProviderConfig) ([]provider.ModelDescriptor, bool) { + modelCatalog, err := s.loadCatalog(ctx, providerCfg) + if err != nil { + return nil, false + } + return modelCatalog.Models, true +} + +func (s *Service) loadCatalog(ctx context.Context, providerCfg config.ProviderConfig) (ModelCatalog, error) { + if s.store == nil { + return ModelCatalog{}, ErrCatalogNotFound + } + + identity, err := providerCfg.Identity() + if err != nil { + return ModelCatalog{}, err + } + return s.store.Load(ctx, identity) +} + +func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.ProviderConfig) ([]provider.ModelDescriptor, bool) { + if !s.registry.Supports(providerCfg.Driver) { + return nil, false + } + + resolved, err := providerCfg.Resolve() + if err != nil { + return nil, false + } + + discovered, err := s.registry.DiscoverModels(ctx, resolved) + if err != nil { + return nil, false + } + + discovered = provider.MergeModelDescriptors(discovered) + if s.store == nil { + return discovered, true + } + + identity, err := providerCfg.Identity() + if err != nil { + return discovered, true + } + + now := s.now() + _ = s.store.Save(ctx, ModelCatalog{ + SchemaVersion: SchemaVersion, + Identity: identity, + FetchedAt: now, + ExpiresAt: now.Add(s.catalogTTL), + Models: discovered, + }) + return discovered, true +} + +func (s *Service) queueRefresh(providerCfg config.ProviderConfig) { + if s.store == nil || !s.registry.Supports(providerCfg.Driver) { + return + } + + identity, err := providerCfg.Identity() + if err != nil { + return + } + + key := identity.Key() + s.refreshMu.Lock() + if _, exists := s.inFlightByID[key]; exists { + s.refreshMu.Unlock() + return + } + s.inFlightByID[key] = struct{}{} + s.refreshMu.Unlock() + + go func() { + defer func() { + s.refreshMu.Lock() + delete(s.inFlightByID, key) + s.refreshMu.Unlock() + }() + + ctx, cancel := context.WithTimeout(context.Background(), s.backgroundTimeout) + defer cancel() + _, _ = s.discoverAndPersist(ctx, providerCfg) + }() +} + +func descriptorsFromIDs(modelIDs []string) []provider.ModelDescriptor { + if len(modelIDs) == 0 { + return nil + } + + descriptors := make([]provider.ModelDescriptor, 0, len(modelIDs)) + for _, modelID := range modelIDs { + id := strings.TrimSpace(modelID) + if id == "" { + continue + } + descriptors = append(descriptors, provider.ModelDescriptor{ + ID: id, + Name: id, + }) + } + if len(descriptors) == 0 { + return nil + } + return descriptors +} diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go new file mode 100644 index 00000000..3e330e3d --- /dev/null +++ b/internal/provider/catalog/service_test.go @@ -0,0 +1,280 @@ +package catalog + +import ( + "context" + "sync" + "testing" + "time" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +func TestNewService(t *testing.T) { + t.Parallel() + + registry := provider.NewRegistry() + store := newMemoryStore() + + service := NewService("", registry, store) + if service == nil { + t.Fatal("expected non-nil service") + } + if service.store != store { + t.Fatal("expected explicit store to be used") + } +} + +func TestListProviderModelsFallsBackToDefaultModelWithoutDiscovery(t *testing.T) { + t.Parallel() + + service := NewService("", provider.NewRegistry(), newMemoryStore()) + models, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + if err != nil { + t.Fatalf("ListProviderModels() error = %v", err) + } + if len(models) != 1 || models[0].ID != config.OpenAIDefaultModel { + t.Fatalf("expected default model fallback, got %+v", models) + } +} + +func TestListProviderModelsSnapshotReturnsDefaultAndRefreshesInBackgroundOnMiss(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + refreshed := make(chan struct{}, 1) + registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]provider.ModelDescriptor, error) { + select { + case refreshed <- struct{}{}: + default: + } + return []provider.ModelDescriptor{{ID: "gpt-4o", Name: "GPT-4o"}}, nil + }) + + store := newMemoryStore() + service := NewService("", registry, store) + service.backgroundTimeout = time.Second + + models, err := service.ListProviderModelsSnapshot(context.Background(), config.OpenAIProvider()) + if err != nil { + t.Fatalf("ListProviderModelsSnapshot() error = %v", err) + } + if len(models) != 1 || models[0].ID != config.OpenAIDefaultModel { + t.Fatalf("expected immediate default model fallback, got %+v", models) + } + + select { + case <-refreshed: + case <-time.After(2 * time.Second): + t.Fatal("expected background refresh to run") + } + + identity, err := config.OpenAIProvider().Identity() + if err != nil { + t.Fatalf("Identity() error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + modelCatalog, err := store.Load(context.Background(), identity) + if err == nil && containsModelDescriptorID(modelCatalog.Models, "gpt-4o") { + return + } + time.Sleep(20 * time.Millisecond) + } + + modelCatalog, err := store.Load(context.Background(), identity) + if err != nil { + t.Fatalf("Load() refreshed catalog error = %v", err) + } + t.Fatalf("expected refreshed catalog to contain gpt-4o, got %+v", modelCatalog.Models) +} + +func TestListProviderModelsDiscoversAndCachesOnMiss(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]provider.ModelDescriptor, error) { + return []provider.ModelDescriptor{{ + ID: "server-model", + Name: "Server Model", + ContextWindow: 32000, + MaxOutputTokens: 4096, + }}, nil + }) + + store := newMemoryStore() + service := NewService("", registry, store) + + models, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + if err != nil { + t.Fatalf("ListProviderModels() error = %v", err) + } + if !containsModelDescriptorID(models, "server-model") { + t.Fatalf("expected discovered model in result, got %+v", models) + } + + identity, err := config.OpenAIProvider().Identity() + if err != nil { + t.Fatalf("Identity() error = %v", err) + } + modelCatalog, err := store.Load(context.Background(), identity) + if err != nil { + t.Fatalf("Load() cached catalog error = %v", err) + } + if !containsModelDescriptorID(modelCatalog.Models, "server-model") { + t.Fatalf("expected cached discovered model, got %+v", modelCatalog.Models) + } +} + +func TestListProviderModelsReturnsStaleCacheAndRefreshesInBackground(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + identity, err := config.OpenAIProvider().Identity() + if err != nil { + t.Fatalf("Identity() error = %v", err) + } + + store := newMemoryStore() + now := time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC) + if err := store.Save(context.Background(), ModelCatalog{ + SchemaVersion: SchemaVersion, + Identity: identity, + FetchedAt: now.Add(-48 * time.Hour), + ExpiresAt: now.Add(-24 * time.Hour), + Models: []provider.ModelDescriptor{{ + ID: "stale-model", + Name: "Stale Model", + }}, + }); err != nil { + t.Fatalf("seed stale catalog: %v", err) + } + + refreshed := make(chan struct{}, 1) + registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]provider.ModelDescriptor, error) { + select { + case refreshed <- struct{}{}: + default: + } + return []provider.ModelDescriptor{{ID: "fresh-model", Name: "Fresh Model"}}, nil + }) + + service := NewService("", registry, store) + service.now = func() time.Time { return now } + service.backgroundTimeout = time.Second + + models, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + if err != nil { + t.Fatalf("ListProviderModels() error = %v", err) + } + if !containsModelDescriptorID(models, "stale-model") { + t.Fatalf("expected stale cached model to be returned immediately, got %+v", models) + } + + select { + case <-refreshed: + case <-time.After(2 * time.Second): + t.Fatal("expected background refresh to run") + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + modelCatalog, err := store.Load(context.Background(), identity) + if err == nil && containsModelDescriptorID(modelCatalog.Models, "fresh-model") { + return + } + time.Sleep(20 * time.Millisecond) + } + + modelCatalog, err := store.Load(context.Background(), identity) + if err != nil { + t.Fatalf("Load() refreshed catalog error = %v", err) + } + t.Fatalf("expected refreshed catalog to contain fresh-model, got %+v", modelCatalog.Models) +} + +func TestDescriptorsFromIDsHelper(t *testing.T) { + t.Parallel() + + models := descriptorsFromIDs([]string{"gpt-4.1", "", "gpt-4o"}) + if len(models) != 2 { + t.Fatalf("expected 2 descriptors, got %d", len(models)) + } + if models[0].ID != "gpt-4.1" || models[1].ID != "gpt-4o" { + t.Fatalf("unexpected descriptors: %+v", models) + } +} + +func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *provider.Registry { + t.Helper() + + registry := provider.NewRegistry() + if err := registry.Register(provider.DriverDefinition{ + Name: name, + Discover: discover, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + return catalogTestProvider{}, nil + }, + }); err != nil { + t.Fatalf("register driver: %v", err) + } + return registry +} + +func containsModelDescriptorID(models []provider.ModelDescriptor, modelID string) bool { + target := config.NormalizeKey(modelID) + if target == "" { + return false + } + + for _, model := range models { + if config.NormalizeKey(model.ID) == target { + return true + } + } + return false +} + +type catalogTestProvider struct{} + +func (catalogTestProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { + return provider.ChatResponse{}, nil +} + +type memoryStore struct { + mu sync.Mutex + catalogs map[string]ModelCatalog +} + +func newMemoryStore() *memoryStore { + return &memoryStore{ + catalogs: map[string]ModelCatalog{}, + } +} + +func (s *memoryStore) Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) { + if err := ctx.Err(); err != nil { + return ModelCatalog{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + modelCatalog, ok := s.catalogs[identity.Key()] + if !ok { + return ModelCatalog{}, ErrCatalogNotFound + } + return modelCatalog, nil +} + +func (s *memoryStore) Save(ctx context.Context, modelCatalog ModelCatalog) error { + if err := ctx.Err(); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.catalogs[modelCatalog.Identity.Key()] = modelCatalog + return nil +} + +const testAPIKeyEnv = "OPENAI_API_KEY" diff --git a/internal/provider/catalog/store.go b/internal/provider/catalog/store.go new file mode 100644 index 00000000..e46dc5f0 --- /dev/null +++ b/internal/provider/catalog/store.go @@ -0,0 +1,121 @@ +package catalog + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +const SchemaVersion = 1 + +var ErrCatalogNotFound = errors.New("provider: model catalog not found") + +// ModelCatalog stores discovered models for a concrete provider endpoint. +type ModelCatalog struct { + SchemaVersion int `json:"schema_version"` + Identity config.ProviderIdentity `json:"identity"` + FetchedAt time.Time `json:"fetched_at"` + ExpiresAt time.Time `json:"expires_at"` + Models []provider.ModelDescriptor `json:"models"` +} + +func (c ModelCatalog) Expired(now time.Time) bool { + return !c.ExpiresAt.IsZero() && !now.Before(c.ExpiresAt) +} + +// Store persists model catalogs keyed by normalized provider identity. +type Store interface { + Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) + Save(ctx context.Context, catalog ModelCatalog) error +} + +type JSONStore struct { + dir string +} + +func NewJSONStore(baseDir string) *JSONStore { + return &JSONStore{ + dir: filepath.Join(strings.TrimSpace(baseDir), "cache", "models"), + } +} + +func (s *JSONStore) Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) { + if err := ctx.Err(); err != nil { + return ModelCatalog{}, err + } + + normalized, err := config.NewProviderIdentity(identity.Driver, identity.BaseURL) + if err != nil { + return ModelCatalog{}, fmt.Errorf("provider: normalize model catalog key: %w", err) + } + + path := s.catalogPath(normalized) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return ModelCatalog{}, ErrCatalogNotFound + } + return ModelCatalog{}, fmt.Errorf("provider: read model catalog: %w", err) + } + + var modelCatalog ModelCatalog + if err := json.Unmarshal(data, &modelCatalog); err != nil { + return ModelCatalog{}, fmt.Errorf("provider: decode model catalog: %w", err) + } + + modelCatalog.Identity = normalized + return normalizeCatalog(modelCatalog), nil +} + +func (s *JSONStore) Save(ctx context.Context, catalog ModelCatalog) error { + if err := ctx.Err(); err != nil { + return err + } + + normalized := normalizeCatalog(catalog) + identity, err := config.NewProviderIdentity(normalized.Identity.Driver, normalized.Identity.BaseURL) + if err != nil { + return fmt.Errorf("provider: normalize model catalog key: %w", err) + } + normalized.Identity = identity + + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return fmt.Errorf("provider: create model catalog dir: %w", err) + } + + data, err := json.MarshalIndent(normalized, "", " ") + if err != nil { + return fmt.Errorf("provider: encode model catalog: %w", err) + } + if len(data) == 0 || data[len(data)-1] != '\n' { + data = append(data, '\n') + } + + if err := os.WriteFile(s.catalogPath(identity), data, 0o644); err != nil { + return fmt.Errorf("provider: write model catalog: %w", err) + } + return nil +} + +func normalizeCatalog(modelCatalog ModelCatalog) ModelCatalog { + if modelCatalog.SchemaVersion == 0 { + modelCatalog.SchemaVersion = SchemaVersion + } + modelCatalog.Models = provider.MergeModelDescriptors(modelCatalog.Models) + return modelCatalog +} + +func (s *JSONStore) catalogPath(identity config.ProviderIdentity) string { + sum := sha256.Sum256([]byte(identity.Key())) + return filepath.Join(s.dir, hex.EncodeToString(sum[:])+".json") +} diff --git a/internal/provider/catalog/store_test.go b/internal/provider/catalog/store_test.go new file mode 100644 index 00000000..6b073beb --- /dev/null +++ b/internal/provider/catalog/store_test.go @@ -0,0 +1,82 @@ +package catalog + +import ( + "context" + "errors" + "testing" + "time" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +func TestJSONStoreRoundTrip(t *testing.T) { + t.Parallel() + + store := NewJSONStore(t.TempDir()) + identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + + expected := ModelCatalog{ + SchemaVersion: SchemaVersion, + Identity: identity, + FetchedAt: time.Date(2026, 4, 2, 10, 0, 0, 0, time.UTC), + ExpiresAt: time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC), + Models: []provider.ModelDescriptor{ + { + ID: "gpt-4.1", + Name: "GPT-4.1", + Description: "Fast flagship", + ContextWindow: 128000, + MaxOutputTokens: 16384, + Capabilities: map[string]bool{ + "tool_call": true, + }, + }, + }, + } + + if err := store.Save(context.Background(), expected); err != nil { + t.Fatalf("Save() error = %v", err) + } + + got, err := store.Load(context.Background(), identity) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.SchemaVersion != expected.SchemaVersion { + t.Fatalf("expected schema version %d, got %d", expected.SchemaVersion, got.SchemaVersion) + } + if got.Identity != expected.Identity { + t.Fatalf("expected identity %+v, got %+v", expected.Identity, got.Identity) + } + if !got.FetchedAt.Equal(expected.FetchedAt) || !got.ExpiresAt.Equal(expected.ExpiresAt) { + t.Fatalf("expected timestamps %+v, got %+v", expected, got) + } + if len(got.Models) != 1 { + t.Fatalf("expected 1 model, got %+v", got.Models) + } + if got.Models[0].ID != expected.Models[0].ID || got.Models[0].Name != expected.Models[0].Name { + t.Fatalf("expected model %+v, got %+v", expected.Models[0], got.Models[0]) + } + if !got.Models[0].Capabilities["tool_call"] { + t.Fatalf("expected capabilities to round-trip, got %+v", got.Models[0].Capabilities) + } +} + +func TestJSONStoreMissingCatalog(t *testing.T) { + t.Parallel() + + store := NewJSONStore(t.TempDir()) + identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + + _, err = store.Load(context.Background(), identity) + if !errors.Is(err, ErrCatalogNotFound) { + t.Fatalf("expected ErrCatalogNotFound, got %v", err) + } +} diff --git a/internal/provider/errors.go b/internal/provider/errors.go index 2280487a..1163c64c 100644 --- a/internal/provider/errors.go +++ b/internal/provider/errors.go @@ -12,8 +12,6 @@ var ( ErrModelNotFound = errors.New("model not found") ErrDriverNotFound = errors.New("provider driver not found") ErrDriverAlreadyRegistered = errors.New("provider: driver already registered") - ErrServiceManagerNil = errors.New("provider: config manager is nil") - ErrServiceRegistryNil = errors.New("provider: registry is nil") ) type ProviderErrorCode string diff --git a/internal/provider/model_catalog_store.go b/internal/provider/model_catalog_store.go deleted file mode 100644 index bca392b9..00000000 --- a/internal/provider/model_catalog_store.go +++ /dev/null @@ -1,130 +0,0 @@ -package provider - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "neo-code/internal/config" -) - -const modelCatalogSchemaVersion = 1 - -var ErrModelCatalogNotFound = errors.New("provider: model catalog not found") - -// ModelCatalog stores discovered provider models for a concrete endpoint. -type ModelCatalog struct { - SchemaVersion int `json:"schema_version"` - Identity config.ProviderIdentity `json:"identity"` - FetchedAt time.Time `json:"fetched_at"` - ExpiresAt time.Time `json:"expires_at"` - Models []ModelDescriptor `json:"models"` -} - -func (c ModelCatalog) Expired(now time.Time) bool { - return !c.ExpiresAt.IsZero() && !now.Before(c.ExpiresAt) -} - -// ModelCatalogStore persists model catalogs keyed by normalized provider identity. -type ModelCatalogStore interface { - Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) - Save(ctx context.Context, catalog ModelCatalog) error -} - -type JSONModelCatalogStore struct { - dir string -} - -func NewJSONModelCatalogStore(baseDir string) *JSONModelCatalogStore { - return &JSONModelCatalogStore{ - dir: filepath.Join(strings.TrimSpace(baseDir), "cache", "models"), - } -} - -func (s *JSONModelCatalogStore) Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) { - if err := ctx.Err(); err != nil { - return ModelCatalog{}, err - } - - normalized, err := config.NewProviderIdentity(identity.Driver, identity.BaseURL) - if err != nil { - return ModelCatalog{}, fmt.Errorf("provider: normalize model catalog key: %w", err) - } - - path, err := s.catalogPathFromNormalized(normalized) - if err != nil { - return ModelCatalog{}, err - } - - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return ModelCatalog{}, ErrModelCatalogNotFound - } - return ModelCatalog{}, fmt.Errorf("provider: read model catalog: %w", err) - } - - var catalog ModelCatalog - if err := json.Unmarshal(data, &catalog); err != nil { - return ModelCatalog{}, fmt.Errorf("provider: decode model catalog: %w", err) - } - - catalog.Identity = normalized - return normalizeModelCatalog(catalog), nil -} - -func (s *JSONModelCatalogStore) Save(ctx context.Context, catalog ModelCatalog) error { - if err := ctx.Err(); err != nil { - return err - } - - normalized := normalizeModelCatalog(catalog) - - normalizedIdentity, err := config.NewProviderIdentity(normalized.Identity.Driver, normalized.Identity.BaseURL) - if err != nil { - return fmt.Errorf("provider: normalize model catalog key: %w", err) - } - normalized.Identity = normalizedIdentity - - path, err := s.catalogPathFromNormalized(normalized.Identity) - if err != nil { - return err - } - - if err := os.MkdirAll(s.dir, 0o755); err != nil { - return fmt.Errorf("provider: create model catalog dir: %w", err) - } - - data, err := json.MarshalIndent(normalized, "", " ") - if err != nil { - return fmt.Errorf("provider: encode model catalog: %w", err) - } - if len(data) == 0 || data[len(data)-1] != '\n' { - data = append(data, '\n') - } - - if err := os.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("provider: write model catalog: %w", err) - } - return nil -} - -func normalizeModelCatalog(catalog ModelCatalog) ModelCatalog { - if catalog.SchemaVersion == 0 { - catalog.SchemaVersion = modelCatalogSchemaVersion - } - catalog.Models = MergeModelDescriptors(catalog.Models) - return catalog -} - -func (s *JSONModelCatalogStore) catalogPathFromNormalized(identity config.ProviderIdentity) (string, error) { - sum := sha256.Sum256([]byte(identity.Key())) - return filepath.Join(s.dir, hex.EncodeToString(sum[:])+".json"), nil -} diff --git a/internal/provider/model_catalog_store_test.go b/internal/provider/model_catalog_store_test.go deleted file mode 100644 index 70b17043..00000000 --- a/internal/provider/model_catalog_store_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package provider - -import ( - "context" - "errors" - "testing" - "time" - - "neo-code/internal/config" -) - -func TestJSONModelCatalogStoreRoundTrip(t *testing.T) { - t.Parallel() - - store := NewJSONModelCatalogStore(t.TempDir()) - identity, err := config.NewProviderIdentity("OPENAI", "https://API.OPENAI.COM/v1/") - if err != nil { - t.Fatalf("NewProviderIdentity() error = %v", err) - } - - expected := ModelCatalog{ - SchemaVersion: modelCatalogSchemaVersion, - Identity: identity, - FetchedAt: time.Date(2026, 4, 2, 10, 0, 0, 0, time.UTC), - ExpiresAt: time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC), - Models: []ModelDescriptor{{ - ID: "gpt-test", - Name: "GPT Test", - Description: "normalized model", - ContextWindow: 128000, - MaxOutputTokens: 8192, - Capabilities: map[string]bool{ - "tool_call": true, - }, - }}, - } - - if err := store.Save(context.Background(), expected); err != nil { - t.Fatalf("Save() error = %v", err) - } - - got, err := store.Load(context.Background(), identity) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if got.Identity.BaseURL != "https://api.openai.com/v1" { - t.Fatalf("expected normalized base url, got %q", got.Identity.BaseURL) - } - if len(got.Models) != 1 { - t.Fatalf("expected 1 model, got %+v", got.Models) - } - if got.Models[0].ContextWindow != 128000 || got.Models[0].MaxOutputTokens != 8192 { - t.Fatalf("expected normalized token fields to survive round-trip, got %+v", got.Models[0]) - } - if !got.Models[0].Capabilities["tool_call"] { - t.Fatalf("expected capabilities to survive round-trip, got %+v", got.Models[0].Capabilities) - } -} - -func TestJSONModelCatalogStoreMissingCatalog(t *testing.T) { - t.Parallel() - - store := NewJSONModelCatalogStore(t.TempDir()) - identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") - if err != nil { - t.Fatalf("NewProviderIdentity() error = %v", err) - } - - _, err = store.Load(context.Background(), identity) - if !errors.Is(err, ErrModelCatalogNotFound) { - t.Fatalf("expected ErrModelCatalogNotFound, got %v", err) - } -} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 7f3cb9df..031c2a99 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -3,12 +3,10 @@ package provider_test import ( "context" "errors" - "sync" "testing" "neo-code/internal/config" "neo-code/internal/provider" - "neo-code/internal/provider/builtin" "neo-code/internal/provider/openai" ) @@ -37,22 +35,6 @@ func newTestRegistry(t *testing.T) *provider.Registry { return registry } -func newTestManager(t *testing.T) *config.Manager { - t.Helper() - - return newTestManagerWithDefaults(t, builtin.DefaultConfig()) -} - -func newTestManagerWithDefaults(t *testing.T, defaults *config.Config) *config.Manager { - t.Helper() - - manager := config.NewManager(config.NewLoader(t.TempDir(), defaults)) - if _, err := manager.Load(context.Background()); err != nil { - t.Fatalf("load config: %v", err) - } - return manager -} - func TestRegistryBuildsRegisteredDriverCaseInsensitively(t *testing.T) { t.Parallel() @@ -98,251 +80,3 @@ func TestRegistryRejectsDuplicateDriverRegistration(t *testing.T) { t.Fatalf("expected duplicate driver registration to fail") } } - -func TestServiceListProvidersFallsBackToProviderDefaultModel(t *testing.T) { - t.Parallel() - - manager := newTestManager(t) - - service := provider.NewService(manager, newTestRegistry(t), newCatalogStoreStub()) - items, err := service.ListProviders(context.Background()) - if err != nil { - t.Fatalf("ListProviders() error = %v", err) - } - expected := map[string]int{ - config.OpenAIName: 1, - config.GeminiName: 1, - config.OpenLLName: 1, - config.QiniuName: 1, - } - if len(items) != len(expected) { - t.Fatalf("expected only builtin providers, got %d", len(items)) - } - - for _, item := range items { - wantModels, ok := expected[item.ID] - if !ok { - t.Fatalf("unexpected builtin provider %q", item.ID) - } - if item.Description != "" { - t.Fatalf("expected provider description to stay empty for hidden metadata, got %q", item.Description) - } - if len(item.Models) != wantModels { - t.Fatalf("expected provider models to fall back to the default model, got %+v", item.Models) - } - delete(expected, item.ID) - } - if len(expected) != 0 { - t.Fatalf("missing builtin providers from catalog: %+v", expected) - } -} - -func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { - manager := newTestManager(t) - registry := newTestRegistry(t) - - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.CurrentModel = "gpt-5.4" - return nil - }); err != nil { - t.Fatalf("seed current model: %v", err) - } - - store := newCatalogStoreStub() - identity, err := config.QiniuProvider().Identity() - if err != nil { - t.Fatalf("qiniu provider identity: %v", err) - } - if err := store.Save(context.Background(), provider.ModelCatalog{ - SchemaVersion: 1, - Identity: identity, - Models: []provider.ModelDescriptor{ - {ID: "qiniu-alt", Name: "qiniu-alt"}, - }, - }); err != nil { - t.Fatalf("seed catalog: %v", err) - } - - service := provider.NewService(manager, registry, store) - - selection, err := service.SelectProvider(context.Background(), config.QiniuName) - if err != nil { - t.Fatalf("SelectProvider() error = %v", err) - } - if selection.ProviderID != config.QiniuName || selection.ModelID != config.QiniuDefaultModel { - t.Fatalf("unexpected selection after switch: %+v", selection) - } - - if _, err := service.SetCurrentModel(context.Background(), "missing-model"); !errors.Is(err, provider.ErrModelNotFound) { - t.Fatalf("expected ErrModelNotFound, got %v", err) - } - - selection, err = service.SetCurrentModel(context.Background(), "qiniu-alt") - if err != nil { - t.Fatalf("SetCurrentModel() error = %v", err) - } - if selection.ModelID != "qiniu-alt" { - t.Fatalf("expected selected model %q, got %+v", "qiniu-alt", selection) - } - - cfg := manager.Get() - selected, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if selected.Name != config.QiniuName { - t.Fatalf("expected selected provider %q, got %+v", config.QiniuName, selected) - } - if selected.Model != config.QiniuDefaultModel || cfg.CurrentModel != "qiniu-alt" { - t.Fatalf("expected provider default and current model to diverge safely, got provider=%q current=%q", selected.Model, cfg.CurrentModel) - } - - reloaded, err := manager.Reload(context.Background()) - if err != nil { - t.Fatalf("Reload() error = %v", err) - } - selected, err = reloaded.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() after reload error = %v", err) - } - if selected.Model != config.QiniuDefaultModel || reloaded.CurrentModel != "qiniu-alt" { - t.Fatalf("expected current model persistence without overriding provider default, got provider=%q current=%q", selected.Model, reloaded.CurrentModel) - } -} - -func TestServiceModelOperationsUseBuiltinConfigEvenWithoutDriver(t *testing.T) { - t.Parallel() - - defaults := builtin.DefaultConfig() - defaults.Providers = []config.ProviderConfig{{ - Name: config.OpenAIName, - Driver: "missing-driver", - BaseURL: config.OpenAIDefaultBaseURL, - Model: "broken-model", - APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, - }} - defaults.SelectedProvider = config.OpenAIName - defaults.CurrentModel = "broken-model" - manager := newTestManagerWithDefaults(t, defaults) - - service := provider.NewService(manager, newTestRegistry(t), newCatalogStoreStub()) - - models, err := service.ListModels(context.Background()) - if err != nil { - t.Fatalf("ListModels() error = %v", err) - } - if len(models) != 1 || models[0].ID != "broken-model" { - t.Fatalf("expected default model fallback, got %+v", models) - } - - if _, err := service.SetCurrentModel(context.Background(), "broken-alt"); !errors.Is(err, provider.ErrModelNotFound) { - t.Fatalf("expected SetCurrentModel() to reject undiscovered model, got %v", err) - } -} - -func TestServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { - t.Parallel() - - defaults := builtin.DefaultConfig() - defaults.Providers = []config.ProviderConfig{{ - Name: config.OpenAIName, - Driver: "missing-driver", - BaseURL: config.OpenAIDefaultBaseURL, - Model: config.OpenAIDefaultModel, - APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, - }} - defaults.SelectedProvider = config.OpenAIName - defaults.CurrentModel = config.OpenAIDefaultModel - manager := newTestManagerWithDefaults(t, defaults) - - service := provider.NewService(manager, newTestRegistry(t), newCatalogStoreStub()) - if _, err := service.SelectProvider(context.Background(), config.OpenAIName); !errors.Is(err, provider.ErrDriverNotFound) { - t.Fatalf("expected SelectProvider() to preserve ErrDriverNotFound, got %v", err) - } -} - -// --- Service.Build 补充测试 --- - -func TestServiceBuildDelegatesToRegistry(t *testing.T) { - t.Parallel() - - registry := newTestRegistry(t) - - resolved := config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{ - Name: "openai-main", - Driver: "openai", - BaseURL: config.OpenAIDefaultBaseURL, - Model: config.OpenAIDefaultModel, - APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, - }, - APIKey: "test-key", - } - - got, err := registry.Build(context.Background(), resolved) - if err != nil { - t.Fatalf("Registry.Build() error = %v", err) - } - if _, ok := got.(*openai.Provider); !ok { - t.Fatalf("expected openai.Provider, got %T", got) - } -} - -func TestServiceBuildReturnsErrorOnNilManager(t *testing.T) { - t.Parallel() - - service := (*provider.Service)(nil) - _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) - if err == nil { - t.Fatalf("expected error for nil Service") - } -} - -func TestServiceBuildReturnsErrorOnNilRegistry(t *testing.T) { - t.Parallel() - - manager := newTestManager(t) - service := provider.NewService(manager, nil, newCatalogStoreStub()) - - _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) - if err == nil { - t.Fatalf("expected error for nil registry") - } -} - -type catalogStoreStub struct { - mu sync.Mutex - catalogs map[string]provider.ModelCatalog -} - -func newCatalogStoreStub() *catalogStoreStub { - return &catalogStoreStub{ - catalogs: map[string]provider.ModelCatalog{}, - } -} - -func (s *catalogStoreStub) Load(ctx context.Context, identity config.ProviderIdentity) (provider.ModelCatalog, error) { - if err := ctx.Err(); err != nil { - return provider.ModelCatalog{}, err - } - - s.mu.Lock() - defer s.mu.Unlock() - - catalog, ok := s.catalogs[identity.Key()] - if !ok { - return provider.ModelCatalog{}, provider.ErrModelCatalogNotFound - } - return catalog, nil -} - -func (s *catalogStoreStub) Save(ctx context.Context, catalog provider.ModelCatalog) error { - if err := ctx.Err(); err != nil { - return err - } - - s.mu.Lock() - defer s.mu.Unlock() - s.catalogs[catalog.Identity.Key()] = catalog - return nil -} diff --git a/internal/provider/selection/service.go b/internal/provider/selection/service.go new file mode 100644 index 00000000..a3f7af53 --- /dev/null +++ b/internal/provider/selection/service.go @@ -0,0 +1,273 @@ +package selection + +import ( + "context" + "errors" + "strings" + + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/catalog" +) + +type modelCatalog interface { + ListProviderModels(ctx context.Context, providerCfg config.ProviderConfig) ([]provider.ModelDescriptor, error) + ListProviderModelsSnapshot(ctx context.Context, providerCfg config.ProviderConfig) ([]provider.ModelDescriptor, error) + ListProviderModelsCached(ctx context.Context, providerCfg config.ProviderConfig) ([]provider.ModelDescriptor, error) +} + +type Service struct { + manager *config.Manager + registry *provider.Registry + catalogs modelCatalog +} + +func NewService(manager *config.Manager, registry *provider.Registry, catalogs *catalog.Service) *Service { + return &Service{ + manager: manager, + registry: registry, + catalogs: catalogs, + } +} + +func (s *Service) ListProviders(ctx context.Context) ([]provider.ProviderCatalogItem, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + cfg := s.manager.Get() + items := make([]provider.ProviderCatalogItem, 0, len(cfg.Providers)) + for _, providerCfg := range cfg.Providers { + if err := ctx.Err(); err != nil { + return nil, err + } + if !s.registry.Supports(providerCfg.Driver) { + continue + } + + models, err := s.catalogs.ListProviderModelsCached(ctx, providerCfg) + if err != nil { + return nil, err + } + items = append(items, providerCatalogItem(providerCfg, models)) + } + + return items, nil +} + +func (s *Service) SelectProvider(ctx context.Context, providerName string) (provider.ProviderSelection, error) { + if err := s.validate(); err != nil { + return provider.ProviderSelection{}, err + } + + cfgSnapshot := s.manager.Get() + providerCfg, err := cfgSnapshot.ProviderByName(providerName) + if err != nil { + return provider.ProviderSelection{}, provider.ErrProviderNotFound + } + if !s.registry.Supports(providerCfg.Driver) { + return provider.ProviderSelection{}, provider.ErrDriverNotFound + } + + models, err := s.catalogs.ListProviderModelsCached(ctx, providerCfg) + if err != nil { + return provider.ProviderSelection{}, err + } + + var selection provider.ProviderSelection + err = s.manager.Update(ctx, func(cfg *config.Config) error { + selected, err := cfg.ProviderByName(providerName) + if err != nil { + return provider.ErrProviderNotFound + } + if !s.registry.Supports(selected.Driver) { + return provider.ErrDriverNotFound + } + + cfg.SelectedProvider = selected.Name + nextModel, _ := resolveCurrentModel(cfg.CurrentModel, models, selected.Model) + cfg.CurrentModel = nextModel + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return provider.ProviderSelection{}, err + } + + return selection, nil +} + +func (s *Service) ListModels(ctx context.Context) ([]provider.ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + selected, err := s.selectedProviderConfig() + if err != nil { + return nil, err + } + return s.catalogs.ListProviderModels(ctx, selected) +} + +func (s *Service) ListModelsSnapshot(ctx context.Context) ([]provider.ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + selected, err := s.selectedProviderConfig() + if err != nil { + return nil, err + } + return s.catalogs.ListProviderModelsSnapshot(ctx, selected) +} + +func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (provider.ProviderSelection, error) { + if err := s.validate(); err != nil { + return provider.ProviderSelection{}, err + } + + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return provider.ProviderSelection{}, provider.ErrModelNotFound + } + + selected, err := s.selectedProviderConfig() + if err != nil { + return provider.ProviderSelection{}, err + } + + models, err := s.catalogs.ListProviderModelsSnapshot(ctx, selected) + if err != nil { + return provider.ProviderSelection{}, err + } + if !containsModelDescriptorID(models, modelID) { + return provider.ProviderSelection{}, provider.ErrModelNotFound + } + + var selection provider.ProviderSelection + err = s.manager.Update(ctx, func(cfg *config.Config) error { + if _, err := cfg.SelectedProviderConfig(); err != nil { + return err + } + cfg.CurrentModel = modelID + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return provider.ProviderSelection{}, err + } + + return selection, nil +} + +func (s *Service) EnsureSelection(ctx context.Context) (provider.ProviderSelection, error) { + if err := s.validate(); err != nil { + return provider.ProviderSelection{}, err + } + if err := ctx.Err(); err != nil { + return provider.ProviderSelection{}, err + } + + cfgSnapshot := s.manager.Get() + selected, err := cfgSnapshot.SelectedProviderConfig() + if err != nil { + return provider.ProviderSelection{}, err + } + + models, err := s.catalogs.ListProviderModelsSnapshot(ctx, selected) + if err != nil { + return provider.ProviderSelection{}, err + } + nextModel, changed := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) + if !changed { + return selectionFromConfig(cfgSnapshot), nil + } + + var selection provider.ProviderSelection + err = s.manager.Update(ctx, func(cfg *config.Config) error { + if _, err := cfg.SelectedProviderConfig(); err != nil { + return err + } + cfg.CurrentModel = nextModel + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return provider.ProviderSelection{}, err + } + + return selection, nil +} + +func (s *Service) validate() error { + if s == nil { + return errors.New("provider selection: service is nil") + } + if s.manager == nil { + return errors.New("provider selection: config manager is nil") + } + if s.registry == nil { + return errors.New("provider selection: registry is nil") + } + if s.catalogs == nil { + return errors.New("provider selection: catalog service is nil") + } + return nil +} + +func (s *Service) selectedProviderConfig() (config.ProviderConfig, error) { + cfg := s.manager.Get() + return cfg.SelectedProviderConfig() +} + +func selectionFromConfig(cfg config.Config) provider.ProviderSelection { + return provider.ProviderSelection{ + ProviderID: cfg.SelectedProvider, + ModelID: cfg.CurrentModel, + } +} + +func resolveCurrentModel(currentModel string, models []provider.ModelDescriptor, fallback string) (string, bool) { + currentModel = strings.TrimSpace(currentModel) + if containsModelDescriptorID(models, currentModel) { + return currentModel, false + } + + fallback = strings.TrimSpace(fallback) + if fallback != "" && containsModelDescriptorID(models, fallback) { + return fallback, currentModel != fallback + } + + return currentModel, false +} + +func providerCatalogItem(cfg config.ProviderConfig, models []provider.ModelDescriptor) provider.ProviderCatalogItem { + return provider.ProviderCatalogItem{ + ID: strings.TrimSpace(cfg.Name), + Name: strings.TrimSpace(cfg.Name), + Models: provider.MergeModelDescriptors(models), + } +} + +func containsModelDescriptorID(models []provider.ModelDescriptor, modelID string) bool { + target := config.NormalizeKey(modelID) + if target == "" { + return false + } + + for _, model := range models { + if config.NormalizeKey(model.ID) == target { + return true + } + } + return false +} diff --git a/internal/provider/selection/service_test.go b/internal/provider/selection/service_test.go new file mode 100644 index 00000000..36072b9a --- /dev/null +++ b/internal/provider/selection/service_test.go @@ -0,0 +1,322 @@ +package selection + +import ( + "context" + "errors" + "sync" + "testing" + + "neo-code/internal/config" + "neo-code/internal/provider" + providercatalog "neo-code/internal/provider/catalog" +) + +func TestServiceListProvidersFallsBackToProviderDefaultModel(t *testing.T) { + t.Parallel() + + manager := newTestManager(t, config.DefaultConfig()) + registry := newRegistry(t, config.OpenAIName, nil) + service := NewService(manager, registry, providercatalog.NewService("", registry, newCatalogStoreStub())) + + items, err := service.ListProviders(context.Background()) + if err != nil { + t.Fatalf("ListProviders() error = %v", err) + } + expected := map[string]int{ + config.OpenAIName: 1, + config.GeminiName: 1, + config.OpenLLName: 1, + config.QiniuName: 1, + } + if len(items) != len(expected) { + t.Fatalf("expected only builtin providers, got %d", len(items)) + } + + for _, item := range items { + wantModels, ok := expected[item.ID] + if !ok { + t.Fatalf("unexpected builtin provider %q", item.ID) + } + if len(item.Models) != wantModels { + t.Fatalf("expected provider models to fall back to the default model, got %+v", item.Models) + } + delete(expected, item.ID) + } + if len(expected) != 0 { + t.Fatalf("missing builtin providers from catalog: %+v", expected) + } +} + +func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { + manager := newTestManager(t, config.DefaultConfig()) + registry := newRegistry(t, config.OpenAIName, nil) + + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.CurrentModel = "gpt-5.4" + return nil + }); err != nil { + t.Fatalf("seed current model: %v", err) + } + + store := newCatalogStoreStub() + identity, err := config.QiniuProvider().Identity() + if err != nil { + t.Fatalf("qiniu provider identity: %v", err) + } + if err := store.Save(context.Background(), providercatalog.ModelCatalog{ + SchemaVersion: providercatalog.SchemaVersion, + Identity: identity, + Models: []provider.ModelDescriptor{ + {ID: "qiniu-alt", Name: "qiniu-alt"}, + }, + }); err != nil { + t.Fatalf("seed catalog: %v", err) + } + + service := NewService(manager, registry, providercatalog.NewService("", registry, store)) + + selection, err := service.SelectProvider(context.Background(), config.QiniuName) + if err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if selection.ProviderID != config.QiniuName || selection.ModelID != config.QiniuDefaultModel { + t.Fatalf("unexpected selection after switch: %+v", selection) + } + + if _, err := service.SetCurrentModel(context.Background(), "missing-model"); !errors.Is(err, provider.ErrModelNotFound) { + t.Fatalf("expected ErrModelNotFound, got %v", err) + } + + selection, err = service.SetCurrentModel(context.Background(), "qiniu-alt") + if err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + if selection.ModelID != "qiniu-alt" { + t.Fatalf("expected selected model %q, got %+v", "qiniu-alt", selection) + } + + cfg := manager.Get() + selected, err := cfg.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if selected.Name != config.QiniuName { + t.Fatalf("expected selected provider %q, got %+v", config.QiniuName, selected) + } + if selected.Model != config.QiniuDefaultModel || cfg.CurrentModel != "qiniu-alt" { + t.Fatalf("expected provider default and current model to diverge safely, got provider=%q current=%q", selected.Model, cfg.CurrentModel) + } +} + +func TestServiceEnsureSelectionRepairsInvalidCurrentModel(t *testing.T) { + t.Parallel() + + manager := newTestManager(t, config.DefaultConfig()) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.CurrentModel = "unsupported-current" + return nil + }); err != nil { + t.Fatalf("seed invalid current model: %v", err) + } + + registry := newRegistry(t, config.OpenAIName, nil) + service := NewService(manager, registry, providercatalog.NewService("", registry, newCatalogStoreStub())) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() error = %v", err) + } + if selection.ProviderID != config.OpenAIName || selection.ModelID != config.OpenAIDefaultModel { + t.Fatalf("unexpected normalized selection: %+v", selection) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.CurrentModel != config.OpenAIDefaultModel { + t.Fatalf("expected rewritten current model %q, got %q", config.OpenAIDefaultModel, reloaded.CurrentModel) + } +} + +func TestServiceModelOperationsUseBuiltinConfigEvenWithoutDriver(t *testing.T) { + t.Parallel() + + defaults := config.DefaultConfig() + defaults.Providers = []config.ProviderConfig{{ + Name: config.OpenAIName, + Driver: "missing-driver", + BaseURL: config.OpenAIDefaultBaseURL, + Model: "broken-model", + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, + }} + defaults.SelectedProvider = config.OpenAIName + defaults.CurrentModel = "broken-model" + + manager := newTestManager(t, defaults) + registry := newRegistry(t, config.OpenAIName, nil) + service := NewService(manager, registry, providercatalog.NewService("", registry, newCatalogStoreStub())) + + models, err := service.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models) != 1 || models[0].ID != "broken-model" { + t.Fatalf("expected default model fallback, got %+v", models) + } + + if _, err := service.SetCurrentModel(context.Background(), "broken-alt"); !errors.Is(err, provider.ErrModelNotFound) { + t.Fatalf("expected SetCurrentModel() to reject undiscovered model, got %v", err) + } +} + +func TestServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { + t.Parallel() + + defaults := config.DefaultConfig() + defaults.Providers = []config.ProviderConfig{{ + Name: config.OpenAIName, + Driver: "missing-driver", + BaseURL: config.OpenAIDefaultBaseURL, + Model: config.OpenAIDefaultModel, + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, + }} + defaults.SelectedProvider = config.OpenAIName + defaults.CurrentModel = config.OpenAIDefaultModel + + manager := newTestManager(t, defaults) + registry := newRegistry(t, config.OpenAIName, nil) + service := NewService(manager, registry, providercatalog.NewService("", registry, newCatalogStoreStub())) + + if _, err := service.SelectProvider(context.Background(), config.OpenAIName); !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected SelectProvider() to preserve ErrDriverNotFound, got %v", err) + } +} + +func TestResolveCurrentModelHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + currentModel string + models []provider.ModelDescriptor + fallback string + expected string + changed bool + }{ + { + name: "current model in list", + currentModel: "gpt-4o", + models: []provider.ModelDescriptor{ + {ID: "gpt-4.1"}, + {ID: "gpt-4o"}, + {ID: "gpt-5.4"}, + }, + fallback: "gpt-4.1", + expected: "gpt-4o", + changed: false, + }, + { + name: "current model falls back to provider default", + currentModel: "unknown-model", + models: []provider.ModelDescriptor{ + {ID: "gpt-4.1"}, + {ID: "gpt-4o"}, + {ID: "gpt-5.4"}, + }, + fallback: "gpt-4.1", + expected: "gpt-4.1", + changed: true, + }, + { + name: "missing fallback keeps current model unchanged", + currentModel: "unknown-model", + models: []provider.ModelDescriptor{ + {ID: "gpt-4o"}, + }, + fallback: "gpt-4.1", + expected: "unknown-model", + changed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, changed := resolveCurrentModel(tt.currentModel, tt.models, tt.fallback) + if got != tt.expected || changed != tt.changed { + t.Fatalf("resolveCurrentModel() = (%q, %v), want (%q, %v)", got, changed, tt.expected, tt.changed) + } + }) + } +} + +func newTestManager(t *testing.T, defaults *config.Config) *config.Manager { + t.Helper() + + manager := config.NewManager(config.NewLoader(t.TempDir(), defaults)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("load config: %v", err) + } + return manager +} + +func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *provider.Registry { + t.Helper() + + registry := provider.NewRegistry() + if err := registry.Register(provider.DriverDefinition{ + Name: name, + Discover: discover, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + return selectionTestProvider{}, nil + }, + }); err != nil { + t.Fatalf("register driver: %v", err) + } + return registry +} + +type selectionTestProvider struct{} + +func (selectionTestProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { + return provider.ChatResponse{}, nil +} + +type catalogStoreStub struct { + mu sync.Mutex + catalogs map[string]providercatalog.ModelCatalog +} + +func newCatalogStoreStub() *catalogStoreStub { + return &catalogStoreStub{ + catalogs: map[string]providercatalog.ModelCatalog{}, + } +} + +func (s *catalogStoreStub) Load(ctx context.Context, identity config.ProviderIdentity) (providercatalog.ModelCatalog, error) { + if err := ctx.Err(); err != nil { + return providercatalog.ModelCatalog{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + catalog, ok := s.catalogs[identity.Key()] + if !ok { + return providercatalog.ModelCatalog{}, providercatalog.ErrCatalogNotFound + } + return catalog, nil +} + +func (s *catalogStoreStub) Save(ctx context.Context, catalog providercatalog.ModelCatalog) error { + if err := ctx.Err(); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + s.catalogs[catalog.Identity.Key()] = catalog + return nil +} diff --git a/internal/provider/service.go b/internal/provider/service.go deleted file mode 100644 index 16b846cb..00000000 --- a/internal/provider/service.go +++ /dev/null @@ -1,407 +0,0 @@ -package provider - -import ( - "context" - "strings" - "sync" - "time" - - "neo-code/internal/config" -) - -const ( - defaultModelCatalogTTL = 24 * time.Hour - defaultBackgroundRefreshTime = 30 * time.Second -) - -type Service struct { - manager *config.Manager - registry *Registry - catalogs ModelCatalogStore - catalogTTL time.Duration - backgroundTimeout time.Duration - now func() time.Time - - refreshMu sync.Mutex - inFlightByID map[string]struct{} -} - -func NewService(manager *config.Manager, registry *Registry, catalogs ModelCatalogStore) *Service { - if catalogs == nil && manager != nil { - catalogs = NewJSONModelCatalogStore(manager.BaseDir()) - } - - return &Service{ - manager: manager, - registry: registry, - catalogs: catalogs, - catalogTTL: defaultModelCatalogTTL, - backgroundTimeout: defaultBackgroundRefreshTime, - now: time.Now, - inFlightByID: map[string]struct{}{}, - } -} - -func (s *Service) ListProviders(ctx context.Context) ([]ProviderCatalogItem, error) { - if err := s.validate(); err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - - cfg := s.manager.Get() - items := make([]ProviderCatalogItem, 0, len(cfg.Providers)) - for _, providerCfg := range cfg.Providers { - if err := ctx.Err(); err != nil { - return nil, err - } - if !s.registry.Supports(providerCfg.Driver) { - continue - } - - models := s.modelsForProvider(ctx, providerCfg, modelQueryOptions{}) - items = append(items, catalogItemFromConfig(providerCfg, models)) - } - - return items, nil -} - -func (s *Service) SelectProvider(ctx context.Context, providerName string) (ProviderSelection, error) { - if err := s.validate(); err != nil { - return ProviderSelection{}, err - } - - cfgSnapshot := s.manager.Get() - providerCfg, err := cfgSnapshot.ProviderByName(providerName) - if err != nil { - return ProviderSelection{}, ErrProviderNotFound - } - if !s.registry.Supports(providerCfg.Driver) { - return ProviderSelection{}, ErrDriverNotFound - } - - models := s.modelsForProvider(ctx, providerCfg, modelQueryOptions{}) - - var selection ProviderSelection - err = s.manager.Update(ctx, func(cfg *config.Config) error { - selected, err := cfg.ProviderByName(providerName) - if err != nil { - return ErrProviderNotFound - } - if !s.registry.Supports(selected.Driver) { - return ErrDriverNotFound - } - - cfg.SelectedProvider = selected.Name - nextModel, _ := resolveCurrentModel(cfg.CurrentModel, models, selected.Model) - cfg.CurrentModel = nextModel - selection = selectionFromConfig(*cfg) - return nil - }) - if err != nil { - return ProviderSelection{}, err - } - - s.queueRefresh(providerCfg) - return selection, nil -} - -func (s *Service) ListModels(ctx context.Context) ([]ModelDescriptor, error) { - if err := s.validate(); err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - - return s.selectedProviderModels(ctx, modelQueryOptions{ - allowSyncRefresh: true, - queueRefresh: true, - }) -} - -func (s *Service) ListModelsSnapshot(ctx context.Context) ([]ModelDescriptor, error) { - if err := s.validate(); err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - - return s.selectedProviderModels(ctx, modelQueryOptions{ - queueRefresh: true, - }) -} - -func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (ProviderSelection, error) { - if err := s.validate(); err != nil { - return ProviderSelection{}, err - } - - modelID = strings.TrimSpace(modelID) - if modelID == "" { - return ProviderSelection{}, ErrModelNotFound - } - - cfgSnapshot := s.manager.Get() - selected, err := cfgSnapshot.SelectedProviderConfig() - if err != nil { - return ProviderSelection{}, err - } - - models := s.modelsForProvider(ctx, selected, modelQueryOptions{ - queueRefresh: true, - }) - if !containsModelDescriptorID(models, modelID) { - return ProviderSelection{}, ErrModelNotFound - } - - var selection ProviderSelection - err = s.manager.Update(ctx, func(cfg *config.Config) error { - if _, err := cfg.SelectedProviderConfig(); err != nil { - return err - } - cfg.CurrentModel = modelID - selection = selectionFromConfig(*cfg) - return nil - }) - if err != nil { - return ProviderSelection{}, err - } - - return selection, nil -} - -func (s *Service) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) { - if err := s.validate(); err != nil { - return nil, err - } - return s.registry.Build(ctx, cfg) -} - -func (s *Service) EnsureSelection(ctx context.Context) (ProviderSelection, error) { - if err := s.validate(); err != nil { - return ProviderSelection{}, err - } - if err := ctx.Err(); err != nil { - return ProviderSelection{}, err - } - - cfgSnapshot := s.manager.Get() - selected, err := cfgSnapshot.SelectedProviderConfig() - if err != nil { - return ProviderSelection{}, err - } - - models := s.modelsForProvider(ctx, selected, modelQueryOptions{ - queueRefresh: true, - }) - nextModel, changed := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) - if !changed { - return selectionFromConfig(cfgSnapshot), nil - } - - var selection ProviderSelection - err = s.manager.Update(ctx, func(cfg *config.Config) error { - if _, err := cfg.SelectedProviderConfig(); err != nil { - return err - } - cfg.CurrentModel = nextModel - selection = selectionFromConfig(*cfg) - return nil - }) - if err != nil { - return ProviderSelection{}, err - } - - return selection, nil -} - -func (s *Service) validate() error { - if s == nil || s.manager == nil { - return ErrServiceManagerNil - } - if s.registry == nil { - return ErrServiceRegistryNil - } - return nil -} - -type modelQueryOptions struct { - allowSyncRefresh bool - queueRefresh bool -} - -func (s *Service) selectedProviderModels(ctx context.Context, options modelQueryOptions) ([]ModelDescriptor, error) { - cfg := s.manager.Get() - selected, err := cfg.SelectedProviderConfig() - if err != nil { - return nil, err - } - - return s.modelsForProvider(ctx, selected, options), nil -} - -func (s *Service) modelsForProvider(ctx context.Context, providerCfg config.ProviderConfig, options modelQueryOptions) []ModelDescriptor { - defaultModels := modelDescriptorsFromIDs([]string{providerCfg.Model}) - - cached, cachedOK := s.loadCatalogModels(ctx, providerCfg) - if !cachedOK && options.allowSyncRefresh { - discovered, ok := s.discoverAndPersist(ctx, providerCfg) - if ok { - cached = discovered - cachedOK = true - } - } - - if options.queueRefresh { - if !cachedOK || s.catalogExpired(ctx, providerCfg) { - s.queueRefresh(providerCfg) - } - } - - return MergeModelDescriptors(cached, defaultModels) -} - -func (s *Service) catalogExpired(ctx context.Context, providerCfg config.ProviderConfig) bool { - catalog, err := s.loadCatalog(ctx, providerCfg) - if err != nil { - return false - } - return catalog.Expired(s.now()) -} - -func (s *Service) loadCatalogModels(ctx context.Context, providerCfg config.ProviderConfig) ([]ModelDescriptor, bool) { - catalog, err := s.loadCatalog(ctx, providerCfg) - if err != nil { - return nil, false - } - return catalog.Models, true -} - -func (s *Service) loadCatalog(ctx context.Context, providerCfg config.ProviderConfig) (ModelCatalog, error) { - if s.catalogs == nil { - return ModelCatalog{}, ErrModelCatalogNotFound - } - - identity, err := providerCfg.Identity() - if err != nil { - return ModelCatalog{}, err - } - return s.catalogs.Load(ctx, identity) -} - -func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.ProviderConfig) ([]ModelDescriptor, bool) { - if s.registry == nil { - return nil, false - } - if !s.registry.Supports(providerCfg.Driver) { - return nil, false - } - - resolved, err := providerCfg.Resolve() - if err != nil { - return nil, false - } - - discovered, err := s.registry.DiscoverModels(ctx, resolved) - if err != nil { - return nil, false - } - - discovered = MergeModelDescriptors(discovered) - if s.catalogs == nil { - return discovered, true - } - - identity, err := providerCfg.Identity() - if err != nil { - return discovered, true - } - - now := s.now() - _ = s.catalogs.Save(ctx, ModelCatalog{ - SchemaVersion: modelCatalogSchemaVersion, - Identity: identity, - FetchedAt: now, - ExpiresAt: now.Add(s.catalogTTL), - Models: discovered, - }) - return discovered, true -} - -func (s *Service) queueRefresh(providerCfg config.ProviderConfig) { - if s.catalogs == nil || s.registry == nil || !s.registry.Supports(providerCfg.Driver) { - return - } - - identity, err := providerCfg.Identity() - if err != nil { - return - } - - key := identity.Key() - s.refreshMu.Lock() - if _, exists := s.inFlightByID[key]; exists { - s.refreshMu.Unlock() - return - } - s.inFlightByID[key] = struct{}{} - s.refreshMu.Unlock() - - go func() { - defer func() { - s.refreshMu.Lock() - delete(s.inFlightByID, key) - s.refreshMu.Unlock() - }() - - ctx, cancel := context.WithTimeout(context.Background(), s.backgroundTimeout) - defer cancel() - _, _ = s.discoverAndPersist(ctx, providerCfg) - }() -} - -func selectionFromConfig(cfg config.Config) ProviderSelection { - return ProviderSelection{ - ProviderID: cfg.SelectedProvider, - ModelID: cfg.CurrentModel, - } -} - -func resolveCurrentModel(currentModel string, models []ModelDescriptor, fallback string) (string, bool) { - currentModel = strings.TrimSpace(currentModel) - if containsModelDescriptorID(models, currentModel) { - return currentModel, false - } - - fallback = strings.TrimSpace(fallback) - if fallback != "" && containsModelDescriptorID(models, fallback) { - return fallback, currentModel != fallback - } - - return currentModel, false -} - -func catalogItemFromConfig(cfg config.ProviderConfig, models []ModelDescriptor) ProviderCatalogItem { - return ProviderCatalogItem{ - ID: strings.TrimSpace(cfg.Name), - Name: strings.TrimSpace(cfg.Name), - Models: MergeModelDescriptors(models), - } -} - -func containsModelDescriptorID(models []ModelDescriptor, modelID string) bool { - target := config.NormalizeKey(modelID) - if target == "" { - return false - } - - for _, model := range models { - if config.NormalizeKey(model.ID) == target { - return true - } - } - return false -} diff --git a/internal/provider/service_test.go b/internal/provider/service_test.go deleted file mode 100644 index f9877da2..00000000 --- a/internal/provider/service_test.go +++ /dev/null @@ -1,541 +0,0 @@ -package provider - -import ( - "context" - "sync" - "testing" - "time" - - "neo-code/internal/config" -) - -func TestNewService(t *testing.T) { - t.Parallel() - - manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) - registry := NewRegistry() - store := newMemoryCatalogStore() - - service := NewService(manager, registry, store) - if service == nil { - t.Fatal("expected non-nil service") - } - if service.catalogs != store { - t.Fatal("expected explicit catalog store to be used") - } -} - -func TestServiceListProvidersReturnsBuiltinCatalog(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() - if err := registry.Register(testDriverDefinition(config.OpenAIName, nil)); err != nil { - t.Fatalf("register test driver: %v", err) - } - - service := NewService(manager, registry, newMemoryCatalogStore()) - items, err := service.ListProviders(context.Background()) - if err != nil { - t.Fatalf("ListProviders() error = %v", err) - } - expected := config.DefaultProviders() - if len(items) != len(expected) { - t.Fatalf("expected %d builtin providers, got %d", len(expected), len(items)) - } - expectedNames := make(map[string]struct{}, len(expected)) - for _, providerCfg := range expected { - expectedNames[providerCfg.Name] = struct{}{} - } - for _, item := range items { - if _, ok := expectedNames[item.ID]; !ok { - t.Fatalf("unexpected provider in catalog: %+v", item) - } - if len(item.Models) != 1 { - t.Fatalf("expected default fallback model for %q, got %+v", item.ID, item.Models) - } - } -} - -func TestServiceSelectProviderFallsBackToProviderDefault(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) - } - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.CurrentModel = "gpt-5.4" - return nil - }); err != nil { - t.Fatalf("seed current model: %v", err) - } - - registry := NewRegistry() - if err := registry.Register(testDriverDefinition(config.OpenAIName, nil)); err != nil { - t.Fatalf("register openai driver: %v", err) - } - - service := NewService(manager, registry, newMemoryCatalogStore()) - selection, err := service.SelectProvider(context.Background(), config.QiniuName) - if err != nil { - t.Fatalf("SelectProvider() error = %v", err) - } - if selection.ProviderID != config.QiniuName || selection.ModelID != config.QiniuDefaultModel { - t.Fatalf("unexpected selection: %+v", selection) - } -} - -func TestServiceEnsureSelectionRepairsInvalidCurrentModel(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) - } - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.CurrentModel = "unsupported-current" - return nil - }); err != nil { - t.Fatalf("seed invalid current model: %v", err) - } - - registry := NewRegistry() - if err := registry.Register(testDriverDefinition(config.OpenAIName, nil)); err != nil { - t.Fatalf("register openai driver: %v", err) - } - - service := NewService(manager, registry, newMemoryCatalogStore()) - selection, err := service.EnsureSelection(context.Background()) - if err != nil { - t.Fatalf("EnsureSelection() error = %v", err) - } - if selection.ProviderID != config.OpenAIName || selection.ModelID != config.OpenAIDefaultModel { - t.Fatalf("unexpected normalized selection: %+v", selection) - } - - reloaded, err := manager.Reload(context.Background()) - if err != nil { - t.Fatalf("Reload() error = %v", err) - } - if reloaded.CurrentModel != config.OpenAIDefaultModel { - t.Fatalf("expected rewritten current model %q, got %q", config.OpenAIDefaultModel, reloaded.CurrentModel) - } -} - -func TestServiceListModelsFallsBackToDefaultModelWithoutDiscovery(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 := NewService(manager, NewRegistry(), newMemoryCatalogStore()) - models, err := service.ListModels(context.Background()) - if err != nil { - t.Fatalf("ListModels() error = %v", err) - } - if len(models) != 1 || models[0].ID != config.OpenAIDefaultModel { - t.Fatalf("expected default model fallback, got %+v", models) - } -} - -func TestServiceListModelsSnapshotReturnsDefaultAndRefreshesInBackgroundOnMiss(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) - } - - store := newMemoryCatalogStore() - refreshed := make(chan struct{}, 1) - registry := NewRegistry() - if err := registry.Register(testDriverDefinition(config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]ModelDescriptor, error) { - select { - case refreshed <- struct{}{}: - default: - } - return []ModelDescriptor{{ID: "gpt-4o", Name: "GPT-4o"}}, nil - })); err != nil { - t.Fatalf("register discovery driver: %v", err) - } - - service := NewService(manager, registry, store) - service.backgroundTimeout = time.Second - - models, err := service.ListModelsSnapshot(context.Background()) - if err != nil { - t.Fatalf("ListModelsSnapshot() error = %v", err) - } - if len(models) != 1 || models[0].ID != config.OpenAIDefaultModel { - t.Fatalf("expected immediate default model fallback, got %+v", models) - } - - select { - case <-refreshed: - case <-time.After(2 * time.Second): - t.Fatal("expected background refresh to run") - } - - identity, err := config.OpenAIProvider().Identity() - if err != nil { - t.Fatalf("Identity() error = %v", err) - } - - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - catalog, err := store.Load(context.Background(), identity) - if err == nil && containsModelDescriptorID(catalog.Models, "gpt-4o") { - return - } - time.Sleep(20 * time.Millisecond) - } - - catalog, err := store.Load(context.Background(), identity) - if err != nil { - t.Fatalf("Load() refreshed catalog error = %v", err) - } - t.Fatalf("expected refreshed catalog to contain gpt-4o, got %+v", catalog.Models) -} - -func TestServiceSetCurrentModelUsesCachedDiscoveredModels(t *testing.T) { - manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) - if _, err := manager.Load(context.Background()); err != nil { - t.Fatalf("Load() error = %v", err) - } - - store := newMemoryCatalogStore() - identity, err := config.OpenAIProvider().Identity() - if err != nil { - t.Fatalf("Identity() error = %v", err) - } - if err := store.Save(context.Background(), ModelCatalog{ - SchemaVersion: modelCatalogSchemaVersion, - Identity: identity, - Models: []ModelDescriptor{ - {ID: config.OpenAIDefaultModel, Name: config.OpenAIDefaultModel}, - {ID: "gpt-4o", Name: "GPT-4o"}, - }, - }); err != nil { - t.Fatalf("seed model catalog: %v", err) - } - - service := NewService(manager, NewRegistry(), store) - selection, err := service.SetCurrentModel(context.Background(), "gpt-4o") - if err != nil { - t.Fatalf("SetCurrentModel() error = %v", err) - } - if selection.ModelID != "gpt-4o" { - t.Fatalf("expected selected model %q, got %+v", "gpt-4o", selection) - } -} - -func TestServiceListModelsDiscoversAndCachesOnMiss(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) - } - - store := newMemoryCatalogStore() - registry := NewRegistry() - if err := registry.Register(testDriverDefinition(config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]ModelDescriptor, error) { - return []ModelDescriptor{{ - ID: "server-model", - Name: "Server Model", - ContextWindow: 32000, - MaxOutputTokens: 4096, - }}, nil - })); err != nil { - t.Fatalf("register discovery driver: %v", err) - } - - service := NewService(manager, registry, store) - models, err := service.ListModels(context.Background()) - if err != nil { - t.Fatalf("ListModels() error = %v", err) - } - if !containsModelDescriptorID(models, "server-model") { - t.Fatalf("expected discovered model in result, got %+v", models) - } - - identity, err := config.OpenAIProvider().Identity() - if err != nil { - t.Fatalf("Identity() error = %v", err) - } - catalog, err := store.Load(context.Background(), identity) - if err != nil { - t.Fatalf("Load() cached catalog error = %v", err) - } - if !containsModelDescriptorID(catalog.Models, "server-model") { - t.Fatalf("expected cached discovered model, got %+v", catalog.Models) - } -} - -func TestServiceListModelsReturnsStaleCacheAndRefreshesInBackground(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) - } - - identity, err := config.OpenAIProvider().Identity() - if err != nil { - t.Fatalf("Identity() error = %v", err) - } - - store := newMemoryCatalogStore() - now := time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC) - if err := store.Save(context.Background(), ModelCatalog{ - SchemaVersion: modelCatalogSchemaVersion, - Identity: identity, - FetchedAt: now.Add(-48 * time.Hour), - ExpiresAt: now.Add(-24 * time.Hour), - Models: []ModelDescriptor{{ - ID: "stale-model", - Name: "Stale Model", - }}, - }); err != nil { - t.Fatalf("seed stale catalog: %v", err) - } - - refreshed := make(chan struct{}, 1) - registry := NewRegistry() - if err := registry.Register(testDriverDefinition(config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]ModelDescriptor, error) { - select { - case refreshed <- struct{}{}: - default: - } - return []ModelDescriptor{{ID: "fresh-model", Name: "Fresh Model"}}, nil - })); err != nil { - t.Fatalf("register custom driver: %v", err) - } - - service := NewService(manager, registry, store) - service.now = func() time.Time { return now } - service.backgroundTimeout = time.Second - - models, err := service.ListModels(context.Background()) - if err != nil { - t.Fatalf("ListModels() error = %v", err) - } - if !containsModelDescriptorID(models, "stale-model") { - t.Fatalf("expected stale cached model to be returned immediately, got %+v", models) - } - - select { - case <-refreshed: - case <-time.After(2 * time.Second): - t.Fatal("expected background refresh to run") - } - - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - catalog, err := store.Load(context.Background(), identity) - if err == nil && containsModelDescriptorID(catalog.Models, "fresh-model") { - return - } - time.Sleep(20 * time.Millisecond) - } - - catalog, err := store.Load(context.Background(), identity) - if err != nil { - t.Fatalf("Load() refreshed catalog error = %v", err) - } - t.Fatalf("expected refreshed catalog to contain fresh-model, got %+v", catalog.Models) -} - -func TestServiceBuildAndValidate(t *testing.T) { - t.Parallel() - - t.Run("build delegates to registry", func(t *testing.T) { - t.Parallel() - - manager := config.NewManager(config.NewLoader(t.TempDir(), testDefaultConfig())) - registry := NewRegistry() - if err := registry.Register(testDriverDefinition("custom", nil)); err != nil { - t.Fatalf("register driver: %v", err) - } - - service := NewService(manager, registry, newMemoryCatalogStore()) - providerInstance, err := service.Build(context.Background(), config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{ - Name: "custom", - Driver: "custom", - BaseURL: "https://example.com/v1", - Model: "model", - APIKeyEnv: "CUSTOM_API_KEY", - }, - APIKey: "test-key", - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if _, ok := providerInstance.(serviceTestProvider); !ok { - t.Fatalf("expected serviceTestProvider, got %T", providerInstance) - } - }) - - t.Run("nil service fails validate", func(t *testing.T) { - t.Parallel() - - var service *Service - if err := service.validate(); err == nil { - t.Fatal("expected validate error for nil service") - } - }) -} - -func TestResolveCurrentModelHelper(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - currentModel string - models []ModelDescriptor - fallback string - expected string - changed bool - }{ - { - name: "current model in list", - currentModel: "gpt-4o", - models: []ModelDescriptor{ - {ID: "gpt-4.1"}, - {ID: "gpt-4o"}, - {ID: "gpt-5.4"}, - }, - fallback: "gpt-4.1", - expected: "gpt-4o", - changed: false, - }, - { - name: "current model falls back to provider default", - currentModel: "unknown-model", - models: []ModelDescriptor{ - {ID: "gpt-4.1"}, - {ID: "gpt-4o"}, - {ID: "gpt-5.4"}, - }, - fallback: "gpt-4.1", - expected: "gpt-4.1", - changed: true, - }, - { - name: "missing fallback keeps current model unchanged", - currentModel: "unknown-model", - models: []ModelDescriptor{ - {ID: "gpt-4o"}, - }, - fallback: "gpt-4.1", - expected: "unknown-model", - changed: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, changed := resolveCurrentModel(tt.currentModel, tt.models, tt.fallback) - if got != tt.expected || changed != tt.changed { - t.Fatalf( - "resolveCurrentModel() = (%q, %v), want (%q, %v)", - got, - changed, - tt.expected, - tt.changed, - ) - } - }) - } -} - -func TestModelDescriptorsFromIDsHelper(t *testing.T) { - t.Parallel() - - models := modelDescriptorsFromIDs([]string{"gpt-4.1", "", "gpt-4o"}) - if len(models) != 2 { - t.Fatalf("expected 2 descriptors, got %d", len(models)) - } - if models[0].ID != "gpt-4.1" || models[1].ID != "gpt-4o" { - t.Fatalf("unexpected descriptors: %+v", models) - } -} - -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 testDriverDefinition(name string, discover DiscoveryFunc) DriverDefinition { - return DriverDefinition{ - Name: name, - Discover: discover, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) { - return serviceTestProvider{}, nil - }, - } -} - -type serviceTestProvider struct{} - -func (serviceTestProvider) Chat(ctx context.Context, req ChatRequest, events chan<- StreamEvent) (ChatResponse, error) { - return ChatResponse{}, nil -} - -type memoryCatalogStore struct { - mu sync.Mutex - catalogs map[string]ModelCatalog -} - -func newMemoryCatalogStore() *memoryCatalogStore { - return &memoryCatalogStore{ - catalogs: map[string]ModelCatalog{}, - } -} - -func (s *memoryCatalogStore) Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) { - if err := ctx.Err(); err != nil { - return ModelCatalog{}, err - } - - s.mu.Lock() - defer s.mu.Unlock() - - catalog, ok := s.catalogs[identity.Key()] - if !ok { - return ModelCatalog{}, ErrModelCatalogNotFound - } - return catalog, nil -} - -func (s *memoryCatalogStore) Save(ctx context.Context, catalog ModelCatalog) error { - if err := ctx.Err(); err != nil { - return err - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.catalogs[catalog.Identity.Key()] = catalog - return nil -} - -const ( - testProviderName = "openai" - testAPIKeyEnv = "OPENAI_API_KEY" -) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f75a4cf8..6665c943 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -12,7 +12,6 @@ import ( "neo-code/internal/config" agentcontext "neo-code/internal/context" "neo-code/internal/provider" - "neo-code/internal/provider/builtin" "neo-code/internal/tools" ) @@ -1237,7 +1236,7 @@ func newRuntimeConfigManager(t *testing.T) *config.Manager { 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())) + manager := config.NewManager(config.NewLoader(t.TempDir(), config.DefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 6942e988..291f4e15 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -13,7 +13,8 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" - "neo-code/internal/provider/builtin" + providercatalog "neo-code/internal/provider/catalog" + providerselection "neo-code/internal/provider/selection" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" ) @@ -1571,14 +1572,14 @@ func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() - manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) + manager := config.NewManager(config.NewLoader(t.TempDir(), config.DefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } return manager } -func newTestProviderService(t *testing.T, manager *config.Manager) *provider.Service { +func newTestProviderService(t *testing.T, manager *config.Manager) *providerselection.Service { t.Helper() registry := provider.NewRegistry() @@ -1591,7 +1592,8 @@ func newTestProviderService(t *testing.T, manager *config.Manager) *provider.Ser if err != nil { t.Fatalf("register provider drivers: %v", err) } - return provider.NewService(manager, registry, newTUITestCatalogStore()) + modelCatalogs := providercatalog.NewService("", registry, newTUITestCatalogStore()) + return providerselection.NewService(manager, registry, modelCatalogs) } type tuiTestProvider struct{} @@ -1601,28 +1603,28 @@ func (tuiTestProvider) Chat(ctx context.Context, req provider.ChatRequest, event } type tuiTestCatalogStore struct { - catalogs map[string]provider.ModelCatalog + catalogs map[string]providercatalog.ModelCatalog } func newTUITestCatalogStore() *tuiTestCatalogStore { return &tuiTestCatalogStore{ - catalogs: map[string]provider.ModelCatalog{}, + catalogs: map[string]providercatalog.ModelCatalog{}, } } -func (s *tuiTestCatalogStore) Load(ctx context.Context, identity config.ProviderIdentity) (provider.ModelCatalog, error) { +func (s *tuiTestCatalogStore) Load(ctx context.Context, identity config.ProviderIdentity) (providercatalog.ModelCatalog, error) { if err := ctx.Err(); err != nil { - return provider.ModelCatalog{}, err + return providercatalog.ModelCatalog{}, err } catalog, ok := s.catalogs[identity.Key()] if !ok { - return provider.ModelCatalog{}, provider.ErrModelCatalogNotFound + return providercatalog.ModelCatalog{}, providercatalog.ErrCatalogNotFound } return catalog, nil } -func (s *tuiTestCatalogStore) Save(ctx context.Context, catalog provider.ModelCatalog) error { +func (s *tuiTestCatalogStore) Save(ctx context.Context, catalog providercatalog.ModelCatalog) error { if err := ctx.Err(); err != nil { return err } From 071e75993121cb623017712898137e4df64fcdc0 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 3 Apr 2026 17:03:21 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(app):=E4=BF=AE=E5=A4=8D=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E9=85=8D=E7=BD=AE=E6=9D=A5=E6=BA=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/bootstrap.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index ab9739cf..6c8a4037 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -38,9 +38,8 @@ func ensureConsoleUTF8() { func NewProgram(ctx context.Context) (*tea.Program, error) { ensureConsoleUTF8() - - loader := config.NewLoader("", builtin.DefaultConfig()) - loader := config.NewLoader("", config.DefaultConfig()) + + loader := config.NewLoader("", config.DefaultConfig()) manager := config.NewManager(loader) if _, err := manager.Load(ctx); err != nil { return nil, err