on main help sign in
Merge pull request #284 from charmbracelet/tools-bg

perf: init tools and providers in background

commitadcf0e1b517b2b13c0217aea682cdd819d7c1910 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 25 Jul 2025, 11:35 (1 year ago) parent582eeb04042317
19 files changed+983−265
go.mod+2−1 internal/app/app.go+1−1 internal/config/config.go+10−13 internal/config/init.go+9−16 internal/config/load.go+20−49 internal/config/load_test.go+106−101 internal/config/provider.go+59−25 internal/config/provider_empty_test.go+47 internal/config/provider_test.go+3−3 internal/csync/maps.go+92 internal/csync/maps_test.go+452 internal/csync/slices.go+36 internal/csync/slices_test.go+87 internal/llm/agent/agent.go+51−43 internal/llm/provider/gemini.go+2−6 internal/llm/provider/openai_test.go+1−2 internal/tui/components/chat/splash/splash.go+1−1 internal/tui/components/dialogs/models/list.go+3−3 internal/tui/components/dialogs/models/models.go+1−1
go.mod+2−1
@@ -42,11 +42,12 @@ require (
4242 github.com/tidwall/sjson v1.2.5
4343 github.com/u-root/u-root v0.14.1-0.20250724181933-b01901710169
4444 github.com/zeebo/xxh3 v1.0.2
45− golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
4645 gopkg.in/natefinch/lumberjack.v2 v2.2.1
4746 mvdan.cc/sh/v3 v3.11.0
4847)
4948
49+require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
50+
5051require (
5152 cloud.google.com/go v0.116.0 // indirect
5253 cloud.google.com/go/auth v0.13.0 // indirect
internal/app/app.go+1−1
@@ -84,7 +84,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
8484 app.setupEvents()
8585
8686 // Initialize LSP clients in the background.
87− go app.initLSPClients(ctx)
87+ app.initLSPClients(ctx)
8888
8989 // TODO: remove the concept of agent config, most likely.
9090 if cfg.IsConfigured() {
internal/config/config.go+10−13
@@ -3,6 +3,7 @@ package config
33import (
44 "context"
55 "fmt"
6+ "log/slog"
67 "net/http"
78 "os"
89 "slices"
@@ -10,9 +11,9 @@ import (
1011 "time"
1112
1213 "github.com/charmbracelet/catwalk/pkg/catwalk"
14+ "github.com/charmbracelet/crush/internal/csync"
1315 "github.com/charmbracelet/crush/internal/env"
1416 "github.com/tidwall/sjson"
15− "golang.org/x/exp/slog"
1617)
1718
1819const (
@@ -240,7 +241,7 @@ type Config struct {
240241 Models map[SelectedModelType]SelectedModel `json:"models,omitempty"`
241242
242243 // The providers that are configured
243− Providers map[string]ProviderConfig `json:"providers,omitempty"`
244+ Providers *csync.Map[string, ProviderConfig] `json:"providers,omitempty"`
244245
245246 MCP MCPs `json:"mcp,omitempty"`
246247
@@ -265,8 +266,8 @@ func (c *Config) WorkingDir() string {
265266}
266267
267268func (c *Config) EnabledProviders() []ProviderConfig {
268− enabled := make([]ProviderConfig, 0, len(c.Providers))
269− for _, p := range c.Providers {
269+ var enabled []ProviderConfig
270+ for _, p := range c.Providers.Seq2() {
270271 if !p.Disable {
271272 enabled = append(enabled, p)
272273 }
@@ -280,7 +281,7 @@ func (c *Config) IsConfigured() bool {
280281}
281282
282283func (c *Config) GetModel(provider, model string) *catwalk.Model {
283− if providerConfig, ok := c.Providers[provider]; ok {
284+ if providerConfig, ok := c.Providers.Get(provider); ok {
284285 for _, m := range providerConfig.Models {
285286 if m.ID == model {
286287 return &m
@@ -295,7 +296,7 @@ func (c *Config) GetProviderForModel(modelType SelectedModelType) *ProviderConfi
295296 if !ok {
296297 return nil
297298 }
298− if providerConfig, ok := c.Providers[model.Provider]; ok {
299+ if providerConfig, ok := c.Providers.Get(model.Provider); ok {
299300 return &providerConfig
300301 }
301302 return nil
@@ -376,14 +377,10 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
376377 return fmt.Errorf("failed to save API key to config file: %w", err)
377378 }
378379
379− if c.Providers == nil {
380− c.Providers = make(map[string]ProviderConfig)
381− }
382−
383− providerConfig, exists := c.Providers[providerID]
380+ providerConfig, exists := c.Providers.Get(providerID)
384381 if exists {
385382 providerConfig.APIKey = apiKey
386− c.Providers[providerID] = providerConfig
383+ c.Providers.Set(providerID, providerConfig)
387384 return nil
388385 }
389386
@@ -412,7 +409,7 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
412409 return fmt.Errorf("provider with ID %s not found in known providers", providerID)
413410 }
414411 // Store the updated provider config
415− c.Providers[providerID] = providerConfig
412+ c.Providers.Set(providerID, providerConfig)
416413 return nil
417414}
418415
internal/config/init.go+9−16
@@ -5,7 +5,6 @@ import (
55 "os"
66 "path/filepath"
77 "strings"
8− "sync"
98 "sync/atomic"
109)
1110
@@ -18,26 +17,20 @@ type ProjectInitFlag struct {
1817}
1918
2019// TODO: we need to remove the global config instance keeping it now just until everything is migrated
21−var (
22− instance atomic.Pointer[Config]
23− cwd string
24− once sync.Once // Ensures the initialization happens only once
25−)
20+var instance atomic.Pointer[Config]
2621
2722func Init(workingDir string, debug bool) (*Config, error) {
28− var err error
29− once.Do(func() {
30− cwd = workingDir
31− var cfg *Config
32− cfg, err = Load(cwd, debug)
33− instance.Store(cfg)
34− })
35−
36− return instance.Load(), err
23+ cfg, err := Load(workingDir, debug)
24+ if err != nil {
25+ return nil, err
26+ }
27+ instance.Store(cfg)
28+ return instance.Load(), nil
3729}
3830
3931func Get() *Config {
40− return instance.Load()
32+ cfg := instance.Load()
33+ return cfg
4134}
4235
4336func ProjectNeedsInitialization() (bool, error) {
internal/config/load.go+20−49
@@ -4,17 +4,17 @@ import (
44 "encoding/json"
55 "fmt"
66 "io"
7+ "log/slog"
78 "os"
89 "path/filepath"
910 "runtime"
1011 "slices"
1112 "strings"
12− "sync"
1313
1414 "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ "github.com/charmbracelet/crush/internal/csync"
1516 "github.com/charmbracelet/crush/internal/env"
1617 "github.com/charmbracelet/crush/internal/log"
17− "golang.org/x/exp/slog"
1818)
1919
2020const catwalkURL = "https://catwalk.charm.sh"
@@ -63,7 +63,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
6363 )
6464
6565 // Load known providers, this loads the config from catwalk
66− providers, err := LoadProviders(catwalk.NewWithURL(catwalkURL))
66+ providers, err := Providers()
6767 if err != nil || len(providers) == 0 {
6868 return nil, fmt.Errorf("failed to load providers: %w", err)
6969 }
@@ -77,35 +77,6 @@ func Load(workingDir string, debug bool) (*Config, error) {
7777 return nil, fmt.Errorf("failed to configure providers: %w", err)
7878 }
7979
80− // Test provider connections in parallel
81− var testResults sync.Map
82− var wg sync.WaitGroup
83−
84− for _, p := range cfg.Providers {
85− if p.Type == catwalk.TypeOpenAI || p.Type == catwalk.TypeAnthropic {
86− wg.Add(1)
87− go func(provider ProviderConfig) {
88− defer wg.Done()
89− err := provider.TestConnection(cfg.resolver)
90− testResults.Store(provider.ID, err == nil)
91− if err != nil {
92− slog.Error("Provider connection test failed", "provider", provider.ID, "error", err)
93− }
94− }(p)
95− }
96− }
97− wg.Wait()
98−
99− // Remove failed providers
100− testResults.Range(func(key, value any) bool {
101− providerID := key.(string)
102− passed := value.(bool)
103− if !passed {
104− delete(cfg.Providers, providerID)
105− }
106− return true
107− })
108−
10980 if !cfg.IsConfigured() {
11081 slog.Warn("No providers configured")
11182 return cfg, nil
@@ -122,12 +93,12 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
12293 knownProviderNames := make(map[string]bool)
12394 for _, p := range knownProviders {
12495 knownProviderNames[string(p.ID)] = true
125− config, configExists := c.Providers[string(p.ID)]
96+ config, configExists := c.Providers.Get(string(p.ID))
12697 // if the user configured a known provider we need to allow it to override a couple of parameters
12798 if configExists {
12899 if config.Disable {
129100 slog.Debug("Skipping provider due to disable flag", "provider", p.ID)
130− delete(c.Providers, string(p.ID))
101+ c.Providers.Del(string(p.ID))
131102 continue
132103 }
133104 if config.BaseURL != "" {
@@ -183,7 +154,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
183154 if !hasVertexCredentials(env) {
184155 if configExists {
185156 slog.Warn("Skipping Vertex AI provider due to missing credentials")
186− delete(c.Providers, string(p.ID))
157+ c.Providers.Del(string(p.ID))
187158 }
188159 continue
189160 }
@@ -194,7 +165,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
194165 if err != nil || endpoint == "" {
195166 if configExists {
196167 slog.Warn("Skipping Azure provider due to missing API endpoint", "provider", p.ID, "error", err)
197− delete(c.Providers, string(p.ID))
168+ c.Providers.Del(string(p.ID))
198169 }
199170 continue
200171 }
@@ -204,7 +175,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
204175 if !hasAWSCredentials(env) {
205176 if configExists {
206177 slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
207− delete(c.Providers, string(p.ID))
178+ c.Providers.Del(string(p.ID))
208179 }
209180 continue
210181 }
@@ -219,16 +190,16 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
219190 if v == "" || err != nil {
220191 if configExists {
221192 slog.Warn("Skipping provider due to missing API key", "provider", p.ID)
222− delete(c.Providers, string(p.ID))
193+ c.Providers.Del(string(p.ID))
223194 }
224195 continue
225196 }
226197 }
227− c.Providers[string(p.ID)] = prepared
198+ c.Providers.Set(string(p.ID), prepared)
228199 }
229200
230201 // validate the custom providers
231− for id, providerConfig := range c.Providers {
202+ for id, providerConfig := range c.Providers.Seq2() {
232203 if knownProviderNames[id] {
233204 continue
234205 }
@@ -245,7 +216,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
245216
246217 if providerConfig.Disable {
247218 slog.Debug("Skipping custom provider due to disable flag", "provider", id)
248− delete(c.Providers, id)
219+ c.Providers.Del(id)
249220 continue
250221 }
251222 if providerConfig.APIKey == "" {
@@ -253,17 +224,17 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
253224 }
254225 if providerConfig.BaseURL == "" {
255226 slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id)
256− delete(c.Providers, id)
227+ c.Providers.Del(id)
257228 continue
258229 }
259230 if len(providerConfig.Models) == 0 {
260231 slog.Warn("Skipping custom provider because the provider has no models", "provider", id)
261− delete(c.Providers, id)
232+ c.Providers.Del(id)
262233 continue
263234 }
264235 if providerConfig.Type != catwalk.TypeOpenAI {
265236 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
266− delete(c.Providers, id)
237+ c.Providers.Del(id)
267238 continue
268239 }
269240
@@ -274,11 +245,11 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
274245 baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
275246 if baseURL == "" || err != nil {
276247 slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id, "error", err)
277− delete(c.Providers, id)
248+ c.Providers.Del(id)
278249 continue
279250 }
280251
281− c.Providers[id] = providerConfig
252+ c.Providers.Set(id, providerConfig)
282253 }
283254 return nil
284255}
@@ -298,7 +269,7 @@ func (c *Config) setDefaults(workingDir string) {
298269 c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
299270 }
300271 if c.Providers == nil {
301− c.Providers = make(map[string]ProviderConfig)
272+ c.Providers = csync.NewMap[string, ProviderConfig]()
302273 }
303274 if c.Models == nil {
304275 c.Models = make(map[SelectedModelType]SelectedModel)
@@ -317,7 +288,7 @@ func (c *Config) setDefaults(workingDir string) {
317288}
318289
319290func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
320− if len(knownProviders) == 0 && len(c.Providers) == 0 {
291+ if len(knownProviders) == 0 && c.Providers.Len() == 0 {
321292 err = fmt.Errorf("no providers configured, please configure at least one provider")
322293 return
323294 }
@@ -325,7 +296,7 @@ func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (large
325296 // Use the first provider enabled based on the known providers order
326297 // if no provider found that is known use the first provider configured
327298 for _, p := range knownProviders {
328− providerConfig, ok := c.Providers[string(p.ID)]
299+ providerConfig, ok := c.Providers.Get(string(p.ID))
329300 if !ok || providerConfig.Disable {
330301 continue
331302 }
internal/config/load_test.go+106−101
@@ -9,6 +9,7 @@ import (
99 "testing"
1010
1111 "github.com/charmbracelet/catwalk/pkg/catwalk"
12+ "github.com/charmbracelet/crush/internal/csync"
1213 "github.com/charmbracelet/crush/internal/env"
1314 "github.com/stretchr/testify/assert"
1415)
@@ -29,9 +30,10 @@ func TestConfig_LoadFromReaders(t *testing.T) {
2930
3031 assert.NoError(t, err)
3132 assert.NotNil(t, loadedConfig)
32− assert.Len(t, loadedConfig.Providers, 1)
33− assert.Equal(t, "key2", loadedConfig.Providers["openai"].APIKey)
34− assert.Equal(t, "https://api.openai.com/v2", loadedConfig.Providers["openai"].BaseURL)
33+ assert.Equal(t, 1, loadedConfig.Providers.Len())
34+ pc, _ := loadedConfig.Providers.Get("openai")
35+ assert.Equal(t, "key2", pc.APIKey)
36+ assert.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
3537}
3638
3739func TestConfig_setDefaults(t *testing.T) {
@@ -73,10 +75,11 @@ func TestConfig_configureProviders(t *testing.T) {
7375 resolver := NewEnvironmentVariableResolver(env)
7476 err := cfg.configureProviders(env, resolver, knownProviders)
7577 assert.NoError(t, err)
76− assert.Len(t, cfg.Providers, 1)
78+ assert.Equal(t, 1, cfg.Providers.Len())
7779
7880 // We want to make sure that we keep the configured API key as a placeholder
79− assert.Equal(t, "$OPENAI_API_KEY", cfg.Providers["openai"].APIKey)
81+ pc, _ := cfg.Providers.Get("openai")
82+ assert.Equal(t, "$OPENAI_API_KEY", pc.APIKey)
8083}
8184
8285func TestConfig_configureProvidersWithOverride(t *testing.T) {
@@ -92,22 +95,21 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
9295 }
9396
9497 cfg := &Config{
95− Providers: map[string]ProviderConfig{
96− "openai": {
97− APIKey: "xyz",
98− BaseURL: "https://api.openai.com/v2",
99− Models: []catwalk.Model{
100− {
101− ID: "test-model",
102− Name: "Updated",
103− },
104− {
105− ID: "another-model",
106− },
107− },
98+ Providers: csync.NewMap[string, ProviderConfig](),
99+ }
100+ cfg.Providers.Set("openai", ProviderConfig{
101+ APIKey: "xyz",
102+ BaseURL: "https://api.openai.com/v2",
103+ Models: []catwalk.Model{
104+ {
105+ ID: "test-model",
106+ Name: "Updated",
107+ },
108+ {
109+ ID: "another-model",
108110 },
109111 },
110− }
112+ })
111113 cfg.setDefaults("/tmp")
112114
113115 env := env.NewFromMap(map[string]string{
@@ -116,13 +118,14 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
116118 resolver := NewEnvironmentVariableResolver(env)
117119 err := cfg.configureProviders(env, resolver, knownProviders)
118120 assert.NoError(t, err)
119− assert.Len(t, cfg.Providers, 1)
121+ assert.Equal(t, 1, cfg.Providers.Len())
120122
121123 // We want to make sure that we keep the configured API key as a placeholder
122− assert.Equal(t, "xyz", cfg.Providers["openai"].APIKey)
123− assert.Equal(t, "https://api.openai.com/v2", cfg.Providers["openai"].BaseURL)
124− assert.Len(t, cfg.Providers["openai"].Models, 2)
125− assert.Equal(t, "Updated", cfg.Providers["openai"].Models[0].Name)
124+ pc, _ := cfg.Providers.Get("openai")
125+ assert.Equal(t, "xyz", pc.APIKey)
126+ assert.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
127+ assert.Len(t, pc.Models, 2)
128+ assert.Equal(t, "Updated", pc.Models[0].Name)
126129}
127130
128131func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
@@ -138,7 +141,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
138141 }
139142
140143 cfg := &Config{
141− Providers: map[string]ProviderConfig{
144+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
142145 "custom": {
143146 APIKey: "xyz",
144147 BaseURL: "https://api.someendpoint.com/v2",
@@ -148,7 +151,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
148151 },
149152 },
150153 },
151− },
154+ }),
152155 }
153156 cfg.setDefaults("/tmp")
154157 env := env.NewFromMap(map[string]string{
@@ -158,16 +161,17 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
158161 err := cfg.configureProviders(env, resolver, knownProviders)
159162 assert.NoError(t, err)
160163 // Should be to because of the env variable
161− assert.Len(t, cfg.Providers, 2)
164+ assert.Equal(t, cfg.Providers.Len(), 2)
162165
163166 // We want to make sure that we keep the configured API key as a placeholder
164− assert.Equal(t, "xyz", cfg.Providers["custom"].APIKey)
167+ pc, _ := cfg.Providers.Get("custom")
168+ assert.Equal(t, "xyz", pc.APIKey)
165169 // Make sure we set the ID correctly
166− assert.Equal(t, "custom", cfg.Providers["custom"].ID)
167− assert.Equal(t, "https://api.someendpoint.com/v2", cfg.Providers["custom"].BaseURL)
168− assert.Len(t, cfg.Providers["custom"].Models, 1)
170+ assert.Equal(t, "custom", pc.ID)
171+ assert.Equal(t, "https://api.someendpoint.com/v2", pc.BaseURL)
172+ assert.Len(t, pc.Models, 1)
169173
170− _, ok := cfg.Providers["openai"]
174+ _, ok := cfg.Providers.Get("openai")
171175 assert.True(t, ok, "OpenAI provider should still be present")
172176}
173177
@@ -192,9 +196,9 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
192196 resolver := NewEnvironmentVariableResolver(env)
193197 err := cfg.configureProviders(env, resolver, knownProviders)
194198 assert.NoError(t, err)
195− assert.Len(t, cfg.Providers, 1)
199+ assert.Equal(t, cfg.Providers.Len(), 1)
196200
197− bedrockProvider, ok := cfg.Providers["bedrock"]
201+ bedrockProvider, ok := cfg.Providers.Get("bedrock")
198202 assert.True(t, ok, "Bedrock provider should be present")
199203 assert.Len(t, bedrockProvider.Models, 1)
200204 assert.Equal(t, "anthropic.claude-sonnet-4-20250514-v1:0", bedrockProvider.Models[0].ID)
@@ -219,7 +223,7 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
219223 err := cfg.configureProviders(env, resolver, knownProviders)
220224 assert.NoError(t, err)
221225 // Provider should not be configured without credentials
222− assert.Len(t, cfg.Providers, 0)
226+ assert.Equal(t, cfg.Providers.Len(), 0)
223227}
224228
225229func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
@@ -267,9 +271,9 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
267271 resolver := NewEnvironmentVariableResolver(env)
268272 err := cfg.configureProviders(env, resolver, knownProviders)
269273 assert.NoError(t, err)
270− assert.Len(t, cfg.Providers, 1)
274+ assert.Equal(t, cfg.Providers.Len(), 1)
271275
272− vertexProvider, ok := cfg.Providers["vertexai"]
276+ vertexProvider, ok := cfg.Providers.Get("vertexai")
273277 assert.True(t, ok, "VertexAI provider should be present")
274278 assert.Len(t, vertexProvider.Models, 1)
275279 assert.Equal(t, "gemini-pro", vertexProvider.Models[0].ID)
@@ -300,7 +304,7 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
300304 err := cfg.configureProviders(env, resolver, knownProviders)
301305 assert.NoError(t, err)
302306 // Provider should not be configured without proper credentials
303− assert.Len(t, cfg.Providers, 0)
307+ assert.Equal(t, cfg.Providers.Len(), 0)
304308}
305309
306310func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
@@ -325,7 +329,7 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
325329 err := cfg.configureProviders(env, resolver, knownProviders)
326330 assert.NoError(t, err)
327331 // Provider should not be configured without project
328− assert.Len(t, cfg.Providers, 0)
332+ assert.Equal(t, cfg.Providers.Len(), 0)
329333}
330334
331335func TestConfig_configureProvidersSetProviderID(t *testing.T) {
@@ -348,16 +352,17 @@ func TestConfig_configureProvidersSetProviderID(t *testing.T) {
348352 resolver := NewEnvironmentVariableResolver(env)
349353 err := cfg.configureProviders(env, resolver, knownProviders)
350354 assert.NoError(t, err)
351− assert.Len(t, cfg.Providers, 1)
355+ assert.Equal(t, cfg.Providers.Len(), 1)
352356
353357 // Provider ID should be set
354− assert.Equal(t, "openai", cfg.Providers["openai"].ID)
358+ pc, _ := cfg.Providers.Get("openai")
359+ assert.Equal(t, "openai", pc.ID)
355360}
356361
357362func TestConfig_EnabledProviders(t *testing.T) {
358363 t.Run("all providers enabled", func(t *testing.T) {
359364 cfg := &Config{
360− Providers: map[string]ProviderConfig{
365+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
361366 "openai": {
362367 ID: "openai",
363368 APIKey: "key1",
@@ -368,7 +373,7 @@ func TestConfig_EnabledProviders(t *testing.T) {
368373 APIKey: "key2",
369374 Disable: false,
370375 },
371− },
376+ }),
372377 }
373378
374379 enabled := cfg.EnabledProviders()
@@ -377,7 +382,7 @@ func TestConfig_EnabledProviders(t *testing.T) {
377382
378383 t.Run("some providers disabled", func(t *testing.T) {
379384 cfg := &Config{
380− Providers: map[string]ProviderConfig{
385+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
381386 "openai": {
382387 ID: "openai",
383388 APIKey: "key1",
@@ -388,7 +393,7 @@ func TestConfig_EnabledProviders(t *testing.T) {
388393 APIKey: "key2",
389394 Disable: true,
390395 },
391− },
396+ }),
392397 }
393398
394399 enabled := cfg.EnabledProviders()
@@ -398,7 +403,7 @@ func TestConfig_EnabledProviders(t *testing.T) {
398403
399404 t.Run("empty providers map", func(t *testing.T) {
400405 cfg := &Config{
401− Providers: map[string]ProviderConfig{},
406+ Providers: csync.NewMap[string, ProviderConfig](),
402407 }
403408
404409 enabled := cfg.EnabledProviders()
@@ -409,13 +414,13 @@ func TestConfig_EnabledProviders(t *testing.T) {
409414func TestConfig_IsConfigured(t *testing.T) {
410415 t.Run("returns true when at least one provider is enabled", func(t *testing.T) {
411416 cfg := &Config{
412− Providers: map[string]ProviderConfig{
417+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
413418 "openai": {
414419 ID: "openai",
415420 APIKey: "key1",
416421 Disable: false,
417422 },
418− },
423+ }),
419424 }
420425
421426 assert.True(t, cfg.IsConfigured())
@@ -423,7 +428,7 @@ func TestConfig_IsConfigured(t *testing.T) {
423428
424429 t.Run("returns false when no providers are configured", func(t *testing.T) {
425430 cfg := &Config{
426− Providers: map[string]ProviderConfig{},
431+ Providers: csync.NewMap[string, ProviderConfig](),
427432 }
428433
429434 assert.False(t, cfg.IsConfigured())
@@ -431,7 +436,7 @@ func TestConfig_IsConfigured(t *testing.T) {
431436
432437 t.Run("returns false when all providers are disabled", func(t *testing.T) {
433438 cfg := &Config{
434− Providers: map[string]ProviderConfig{
439+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
435440 "openai": {
436441 ID: "openai",
437442 APIKey: "key1",
@@ -442,7 +447,7 @@ func TestConfig_IsConfigured(t *testing.T) {
442447 APIKey: "key2",
443448 Disable: true,
444449 },
445− },
450+ }),
446451 }
447452
448453 assert.False(t, cfg.IsConfigured())
@@ -462,11 +467,11 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
462467 }
463468
464469 cfg := &Config{
465− Providers: map[string]ProviderConfig{
470+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
466471 "openai": {
467472 Disable: true,
468473 },
469− },
474+ }),
470475 }
471476 cfg.setDefaults("/tmp")
472477
@@ -478,15 +483,15 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
478483 assert.NoError(t, err)
479484
480485 // Provider should be removed from config when disabled
481− assert.Len(t, cfg.Providers, 0)
482− _, exists := cfg.Providers["openai"]
486+ assert.Equal(t, cfg.Providers.Len(), 0)
487+ _, exists := cfg.Providers.Get("openai")
483488 assert.False(t, exists)
484489}
485490
486491func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
487492 t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) {
488493 cfg := &Config{
489− Providers: map[string]ProviderConfig{
494+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
490495 "custom": {
491496 BaseURL: "https://api.custom.com/v1",
492497 Models: []catwalk.Model{{
@@ -496,7 +501,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
496501 "openai": {
497502 APIKey: "$MISSING",
498503 },
499− },
504+ }),
500505 }
501506 cfg.setDefaults("/tmp")
502507
@@ -505,21 +510,21 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
505510 err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
506511 assert.NoError(t, err)
507512
508− assert.Len(t, cfg.Providers, 1)
509− _, exists := cfg.Providers["custom"]
513+ assert.Equal(t, cfg.Providers.Len(), 1)
514+ _, exists := cfg.Providers.Get("custom")
510515 assert.True(t, exists)
511516 })
512517
513518 t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
514519 cfg := &Config{
515− Providers: map[string]ProviderConfig{
520+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
516521 "custom": {
517522 APIKey: "test-key",
518523 Models: []catwalk.Model{{
519524 ID: "test-model",
520525 }},
521526 },
522− },
527+ }),
523528 }
524529 cfg.setDefaults("/tmp")
525530
@@ -528,20 +533,20 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
528533 err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
529534 assert.NoError(t, err)
530535
531− assert.Len(t, cfg.Providers, 0)
532− _, exists := cfg.Providers["custom"]
536+ assert.Equal(t, cfg.Providers.Len(), 0)
537+ _, exists := cfg.Providers.Get("custom")
533538 assert.False(t, exists)
534539 })
535540
536541 t.Run("custom provider with no models is removed", func(t *testing.T) {
537542 cfg := &Config{
538− Providers: map[string]ProviderConfig{
543+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
539544 "custom": {
540545 APIKey: "test-key",
541546 BaseURL: "https://api.custom.com/v1",
542547 Models: []catwalk.Model{},
543548 },
544− },
549+ }),
545550 }
546551 cfg.setDefaults("/tmp")
547552
@@ -550,14 +555,14 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
550555 err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
551556 assert.NoError(t, err)
552557
553− assert.Len(t, cfg.Providers, 0)
554− _, exists := cfg.Providers["custom"]
558+ assert.Equal(t, cfg.Providers.Len(), 0)
559+ _, exists := cfg.Providers.Get("custom")
555560 assert.False(t, exists)
556561 })
557562
558563 t.Run("custom provider with unsupported type is removed", func(t *testing.T) {
559564 cfg := &Config{
560− Providers: map[string]ProviderConfig{
565+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
561566 "custom": {
562567 APIKey: "test-key",
563568 BaseURL: "https://api.custom.com/v1",
@@ -566,7 +571,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
566571 ID: "test-model",
567572 }},
568573 },
569− },
574+ }),
570575 }
571576 cfg.setDefaults("/tmp")
572577
@@ -575,14 +580,14 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
575580 err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
576581 assert.NoError(t, err)
577582
578− assert.Len(t, cfg.Providers, 0)
579− _, exists := cfg.Providers["custom"]
583+ assert.Equal(t, cfg.Providers.Len(), 0)
584+ _, exists := cfg.Providers.Get("custom")
580585 assert.False(t, exists)
581586 })
582587
583588 t.Run("valid custom provider is kept and ID is set", func(t *testing.T) {
584589 cfg := &Config{
585− Providers: map[string]ProviderConfig{
590+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
586591 "custom": {
587592 APIKey: "test-key",
588593 BaseURL: "https://api.custom.com/v1",
@@ -591,7 +596,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
591596 ID: "test-model",
592597 }},
593598 },
594− },
599+ }),
595600 }
596601 cfg.setDefaults("/tmp")
597602
@@ -600,8 +605,8 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
600605 err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
601606 assert.NoError(t, err)
602607
603− assert.Len(t, cfg.Providers, 1)
604− customProvider, exists := cfg.Providers["custom"]
608+ assert.Equal(t, cfg.Providers.Len(), 1)
609+ customProvider, exists := cfg.Providers.Get("custom")
605610 assert.True(t, exists)
606611 assert.Equal(t, "custom", customProvider.ID)
607612 assert.Equal(t, "test-key", customProvider.APIKey)
@@ -610,7 +615,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
610615
611616 t.Run("disabled custom provider is removed", func(t *testing.T) {
612617 cfg := &Config{
613− Providers: map[string]ProviderConfig{
618+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
614619 "custom": {
615620 APIKey: "test-key",
616621 BaseURL: "https://api.custom.com/v1",
@@ -620,7 +625,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
620625 ID: "test-model",
621626 }},
622627 },
623− },
628+ }),
624629 }
625630 cfg.setDefaults("/tmp")
626631
@@ -629,8 +634,8 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
629634 err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
630635 assert.NoError(t, err)
631636
632− assert.Len(t, cfg.Providers, 0)
633− _, exists := cfg.Providers["custom"]
637+ assert.Equal(t, cfg.Providers.Len(), 0)
638+ _, exists := cfg.Providers.Get("custom")
634639 assert.False(t, exists)
635640 })
636641}
@@ -649,11 +654,11 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
649654 }
650655
651656 cfg := &Config{
652− Providers: map[string]ProviderConfig{
657+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
653658 "vertexai": {
654659 BaseURL: "custom-url",
655660 },
656− },
661+ }),
657662 }
658663 cfg.setDefaults("/tmp")
659664
@@ -664,8 +669,8 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
664669 err := cfg.configureProviders(env, resolver, knownProviders)
665670 assert.NoError(t, err)
666671
667− assert.Len(t, cfg.Providers, 0)
668− _, exists := cfg.Providers["vertexai"]
672+ assert.Equal(t, cfg.Providers.Len(), 0)
673+ _, exists := cfg.Providers.Get("vertexai")
669674 assert.False(t, exists)
670675 })
671676
@@ -682,11 +687,11 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
682687 }
683688
684689 cfg := &Config{
685− Providers: map[string]ProviderConfig{
690+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
686691 "bedrock": {
687692 BaseURL: "custom-url",
688693 },
689− },
694+ }),
690695 }
691696 cfg.setDefaults("/tmp")
692697
@@ -695,8 +700,8 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
695700 err := cfg.configureProviders(env, resolver, knownProviders)
696701 assert.NoError(t, err)
697702
698− assert.Len(t, cfg.Providers, 0)
699− _, exists := cfg.Providers["bedrock"]
703+ assert.Equal(t, cfg.Providers.Len(), 0)
704+ _, exists := cfg.Providers.Get("bedrock")
700705 assert.False(t, exists)
701706 })
702707
@@ -713,11 +718,11 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
713718 }
714719
715720 cfg := &Config{
716− Providers: map[string]ProviderConfig{
721+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
717722 "openai": {
718723 BaseURL: "custom-url",
719724 },
720− },
725+ }),
721726 }
722727 cfg.setDefaults("/tmp")
723728
@@ -726,8 +731,8 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
726731 err := cfg.configureProviders(env, resolver, knownProviders)
727732 assert.NoError(t, err)
728733
729− assert.Len(t, cfg.Providers, 0)
730− _, exists := cfg.Providers["openai"]
734+ assert.Equal(t, cfg.Providers.Len(), 0)
735+ _, exists := cfg.Providers.Get("openai")
731736 assert.False(t, exists)
732737 })
733738
@@ -744,11 +749,11 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
744749 }
745750
746751 cfg := &Config{
747− Providers: map[string]ProviderConfig{
752+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
748753 "openai": {
749754 APIKey: "test-key",
750755 },
751− },
756+ }),
752757 }
753758 cfg.setDefaults("/tmp")
754759
@@ -759,8 +764,8 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
759764 err := cfg.configureProviders(env, resolver, knownProviders)
760765 assert.NoError(t, err)
761766
762− assert.Len(t, cfg.Providers, 1)
763− _, exists := cfg.Providers["openai"]
767+ assert.Equal(t, cfg.Providers.Len(), 1)
768+ _, exists := cfg.Providers.Get("openai")
764769 assert.True(t, exists)
765770 })
766771}
@@ -883,7 +888,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
883888 }
884889
885890 cfg := &Config{
886− Providers: map[string]ProviderConfig{
891+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
887892 "custom": {
888893 APIKey: "test-key",
889894 BaseURL: "https://api.custom.com/v1",
@@ -894,7 +899,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
894899 },
895900 },
896901 },
897− },
902+ }),
898903 }
899904 cfg.setDefaults("/tmp")
900905 env := env.NewFromMap(map[string]string{})
@@ -932,13 +937,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
932937 }
933938
934939 cfg := &Config{
935− Providers: map[string]ProviderConfig{
940+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
936941 "custom": {
937942 APIKey: "test-key",
938943 BaseURL: "https://api.custom.com/v1",
939944 Models: []catwalk.Model{},
940945 },
941− },
946+ }),
942947 }
943948 cfg.setDefaults("/tmp")
944949 env := env.NewFromMap(map[string]string{})
@@ -969,7 +974,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
969974 }
970975
971976 cfg := &Config{
972− Providers: map[string]ProviderConfig{
977+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
973978 "custom": {
974979 APIKey: "test-key",
975980 BaseURL: "https://api.custom.com/v1",
@@ -980,7 +985,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
980985 },
981986 },
982987 },
983− },
988+ }),
984989 }
985990 cfg.setDefaults("/tmp")
986991 env := env.NewFromMap(map[string]string{})
internal/config/provider.go+59−25
@@ -2,10 +2,13 @@ package config
22
33import (
44 "encoding/json"
5+ "fmt"
6+ "log/slog"
57 "os"
68 "path/filepath"
79 "runtime"
810 "sync"
11+ "time"
912
1013 "github.com/charmbracelet/catwalk/pkg/catwalk"
1114)
@@ -41,57 +44,88 @@ func providerCacheFileData() string {
4144}
4245
4346func saveProvidersInCache(path string, providers []catwalk.Provider) error {
44− dir := filepath.Dir(path)
45− if err := os.MkdirAll(dir, 0o755); err != nil {
46− return err
47+ slog.Info("Saving cached provider data", "path", path)
48+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
49+ return fmt.Errorf("failed to create directory for provider cache: %w", err)
4750 }
4851
4952 data, err := json.MarshalIndent(providers, "", " ")
5053 if err != nil {
51− return err
54+ return fmt.Errorf("failed to marshal provider data: %w", err)
5255 }
5356
54− return os.WriteFile(path, data, 0o644)
57+ if err := os.WriteFile(path, data, 0o644); err != nil {
58+ return fmt.Errorf("failed to write provider data to cache: %w", err)
59+ }
60+ return nil
5561}
5662
5763func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
5864 data, err := os.ReadFile(path)
5965 if err != nil {
60− return nil, err
66+ return nil, fmt.Errorf("failed to read provider cache file: %w", err)
6167 }
6268
6369 var providers []catwalk.Provider
64− err = json.Unmarshal(data, &providers)
65− return providers, err
66−}
67−
68−func loadProviders(path string, client ProviderClient) ([]catwalk.Provider, error) {
69− providers, err := client.GetProviders()
70− if err != nil {
71− fallbackToCache, err := loadProvidersFromCache(path)
72− if err != nil {
73− return nil, err
74− }
75− providers = fallbackToCache
76− } else {
77− if err := saveProvidersInCache(path, providerList); err != nil {
78− return nil, err
79− }
70+ if err := json.Unmarshal(data, &providers); err != nil {
71+ return nil, fmt.Errorf("failed to unmarshal provider data from cache: %w", err)
8072 }
8173 return providers, nil
8274}
8375
8476func Providers() ([]catwalk.Provider, error) {
85− return LoadProviders(catwalk.NewWithURL(catwalkURL))
77+ client := catwalk.NewWithURL(catwalkURL)
78+ path := providerCacheFileData()
79+ return loadProvidersOnce(client, path)
8680}
8781
88−func LoadProviders(client ProviderClient) ([]catwalk.Provider, error) {
82+func loadProvidersOnce(client ProviderClient, path string) ([]catwalk.Provider, error) {
8983 var err error
9084 providerOnce.Do(func() {
91− providerList, err = loadProviders(providerCacheFileData(), client)
85+ providerList, err = loadProviders(client, path)
9286 })
9387 if err != nil {
9488 return nil, err
9589 }
9690 return providerList, nil
9791}
92+
93+func loadProviders(client ProviderClient, path string) (providerList []catwalk.Provider, err error) {
94+ // if cache is not stale, load from it
95+ stale, exists := isCacheStale(path)
96+ if !stale {
97+ slog.Info("Using cached provider data", "path", path)
98+ providerList, err = loadProvidersFromCache(path)
99+ if len(providerList) > 0 && err == nil {
100+ go func() {
101+ slog.Info("Updating provider cache in background")
102+ updated, uerr := client.GetProviders()
103+ if len(updated) > 0 && uerr == nil {
104+ _ = saveProvidersInCache(path, updated)
105+ }
106+ }()
107+ return
108+ }
109+ }
110+
111+ slog.Info("Getting live provider data")
112+ providerList, err = client.GetProviders()
113+ if len(providerList) > 0 && err == nil {
114+ err = saveProvidersInCache(path, providerList)
115+ return
116+ }
117+ if !exists {
118+ err = fmt.Errorf("failed to load providers")
119+ return
120+ }
121+ providerList, err = loadProvidersFromCache(path)
122+ return
123+}
124+
125+func isCacheStale(path string) (stale, exists bool) {
126+ info, err := os.Stat(path)
127+ if err != nil {
128+ return true, false
129+ }
130+ return time.Since(info.ModTime()) > 24*time.Hour, true
131+}
internal/config/provider_empty_test.gonew file+47
@@ -0,0 +1,47 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "testing"
7+
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type emptyProviderClient struct{}
13+
14+func (m *emptyProviderClient) GetProviders() ([]catwalk.Provider, error) {
15+ return []catwalk.Provider{}, nil
16+}
17+
18+func TestProvider_loadProvidersEmptyResult(t *testing.T) {
19+ client := &emptyProviderClient{}
20+ tmpPath := t.TempDir() + "/providers.json"
21+
22+ providers, err := loadProviders(client, tmpPath)
23+ require.EqualError(t, err, "failed to load providers")
24+ require.Empty(t, providers)
25+ require.Len(t, providers, 0)
26+
27+ // Check that no cache file was created for empty results
28+ require.NoFileExists(t, tmpPath, "Cache file should not exist for empty results")
29+}
30+
31+func TestProvider_loadProvidersEmptyCache(t *testing.T) {
32+ client := &mockProviderClient{shouldFail: false}
33+ tmpPath := t.TempDir() + "/providers.json"
34+
35+ // Create an empty cache file
36+ emptyProviders := []catwalk.Provider{}
37+ data, err := json.Marshal(emptyProviders)
38+ require.NoError(t, err)
39+ require.NoError(t, os.WriteFile(tmpPath, data, 0o644))
40+
41+ // Should refresh and get real providers instead of using empty cache
42+ providers, err := loadProviders(client, tmpPath)
43+ require.NoError(t, err)
44+ require.NotNil(t, providers)
45+ require.Len(t, providers, 1)
46+ require.Equal(t, "Mock", providers[0].Name)
47+}
internal/config/provider_test.go+3−3
@@ -28,7 +28,7 @@ func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
2828func TestProvider_loadProvidersNoIssues(t *testing.T) {
2929 client := &mockProviderClient{shouldFail: false}
3030 tmpPath := t.TempDir() + "/providers.json"
31− providers, err := loadProviders(tmpPath, client)
31+ providers, err := loadProviders(client, tmpPath)
3232 assert.NoError(t, err)
3333 assert.NotNil(t, providers)
3434 assert.Len(t, providers, 1)
@@ -57,7 +57,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
5757 if err != nil {
5858 t.Fatalf("Failed to write old providers to file: %v", err)
5959 }
60− providers, err := loadProviders(tmpPath, client)
60+ providers, err := loadProviders(client, tmpPath)
6161 assert.NoError(t, err)
6262 assert.NotNil(t, providers)
6363 assert.Len(t, providers, 1)
@@ -67,7 +67,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
6767func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
6868 client := &mockProviderClient{shouldFail: true}
6969 tmpPath := t.TempDir() + "/providers.json"
70− providers, err := loadProviders(tmpPath, client)
70+ providers, err := loadProviders(client, tmpPath)
7171 assert.Error(t, err)
7272 assert.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
7373}
internal/csync/maps.gonew file+92
@@ -0,0 +1,92 @@
1+package csync
2+
3+import (
4+ "encoding/json"
5+ "iter"
6+ "maps"
7+ "sync"
8+)
9+
10+// Map is a concurrent map implementation that provides thread-safe access.
11+type Map[K comparable, V any] struct {
12+ inner map[K]V
13+ mu sync.RWMutex
14+}
15+
16+// NewMap creates a new thread-safe map with the specified key and value types.
17+func NewMap[K comparable, V any]() *Map[K, V] {
18+ return &Map[K, V]{
19+ inner: make(map[K]V),
20+ }
21+}
22+
23+// NewMapFrom creates a new thread-safe map from an existing map.
24+func NewMapFrom[K comparable, V any](m map[K]V) *Map[K, V] {
25+ return &Map[K, V]{
26+ inner: m,
27+ }
28+}
29+
30+// Set sets the value for the specified key in the map.
31+func (m *Map[K, V]) Set(key K, value V) {
32+ m.mu.Lock()
33+ defer m.mu.Unlock()
34+ m.inner[key] = value
35+}
36+
37+// Del deletes the specified key from the map.
38+func (m *Map[K, V]) Del(key K) {
39+ m.mu.Lock()
40+ defer m.mu.Unlock()
41+ delete(m.inner, key)
42+}
43+
44+// Get gets the value for the specified key from the map.
45+func (m *Map[K, V]) Get(key K) (V, bool) {
46+ m.mu.RLock()
47+ defer m.mu.RUnlock()
48+ v, ok := m.inner[key]
49+ return v, ok
50+}
51+
52+// Len returns the number of items in the map.
53+func (m *Map[K, V]) Len() int {
54+ m.mu.RLock()
55+ defer m.mu.RUnlock()
56+ return len(m.inner)
57+}
58+
59+// Seq2 returns an iter.Seq2 that yields key-value pairs from the map.
60+func (m *Map[K, V]) Seq2() iter.Seq2[K, V] {
61+ dst := make(map[K]V)
62+ m.mu.RLock()
63+ maps.Copy(dst, m.inner)
64+ m.mu.RUnlock()
65+ return func(yield func(K, V) bool) {
66+ for k, v := range dst {
67+ if !yield(k, v) {
68+ return
69+ }
70+ }
71+ }
72+}
73+
74+var (
75+ _ json.Unmarshaler = &Map[string, any]{}
76+ _ json.Marshaler = &Map[string, any]{}
77+)
78+
79+// UnmarshalJSON implements json.Unmarshaler.
80+func (m *Map[K, V]) UnmarshalJSON(data []byte) error {
81+ m.mu.Lock()
82+ defer m.mu.Unlock()
83+ m.inner = make(map[K]V)
84+ return json.Unmarshal(data, &m.inner)
85+}
86+
87+// MarshalJSON implements json.Marshaler.
88+func (m *Map[K, V]) MarshalJSON() ([]byte, error) {
89+ m.mu.RLock()
90+ defer m.mu.RUnlock()
91+ return json.Marshal(m.inner)
92+}
internal/csync/maps_test.gonew file+452
@@ -0,0 +1,452 @@
1+package csync
2+
3+import (
4+ "encoding/json"
5+ "maps"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestNewMap(t *testing.T) {
13+ t.Parallel()
14+
15+ m := NewMap[string, int]()
16+ assert.NotNil(t, m)
17+ assert.NotNil(t, m.inner)
18+ assert.Equal(t, 0, m.Len())
19+}
20+
21+func TestNewMapFrom(t *testing.T) {
22+ t.Parallel()
23+
24+ original := map[string]int{
25+ "key1": 1,
26+ "key2": 2,
27+ }
28+
29+ m := NewMapFrom(original)
30+ assert.NotNil(t, m)
31+ assert.Equal(t, original, m.inner)
32+ assert.Equal(t, 2, m.Len())
33+
34+ value, ok := m.Get("key1")
35+ assert.True(t, ok)
36+ assert.Equal(t, 1, value)
37+}
38+
39+func TestMap_Set(t *testing.T) {
40+ t.Parallel()
41+
42+ m := NewMap[string, int]()
43+
44+ m.Set("key1", 42)
45+ value, ok := m.Get("key1")
46+ assert.True(t, ok)
47+ assert.Equal(t, 42, value)
48+ assert.Equal(t, 1, m.Len())
49+
50+ m.Set("key1", 100)
51+ value, ok = m.Get("key1")
52+ assert.True(t, ok)
53+ assert.Equal(t, 100, value)
54+ assert.Equal(t, 1, m.Len())
55+}
56+
57+func TestMap_Get(t *testing.T) {
58+ t.Parallel()
59+
60+ m := NewMap[string, int]()
61+
62+ value, ok := m.Get("nonexistent")
63+ assert.False(t, ok)
64+ assert.Equal(t, 0, value)
65+
66+ m.Set("key1", 42)
67+ value, ok = m.Get("key1")
68+ assert.True(t, ok)
69+ assert.Equal(t, 42, value)
70+}
71+
72+func TestMap_Del(t *testing.T) {
73+ t.Parallel()
74+
75+ m := NewMap[string, int]()
76+ m.Set("key1", 42)
77+ m.Set("key2", 100)
78+
79+ assert.Equal(t, 2, m.Len())
80+
81+ m.Del("key1")
82+ _, ok := m.Get("key1")
83+ assert.False(t, ok)
84+ assert.Equal(t, 1, m.Len())
85+
86+ value, ok := m.Get("key2")
87+ assert.True(t, ok)
88+ assert.Equal(t, 100, value)
89+
90+ m.Del("nonexistent")
91+ assert.Equal(t, 1, m.Len())
92+}
93+
94+func TestMap_Len(t *testing.T) {
95+ t.Parallel()
96+
97+ m := NewMap[string, int]()
98+ assert.Equal(t, 0, m.Len())
99+
100+ m.Set("key1", 1)
101+ assert.Equal(t, 1, m.Len())
102+
103+ m.Set("key2", 2)
104+ assert.Equal(t, 2, m.Len())
105+
106+ m.Del("key1")
107+ assert.Equal(t, 1, m.Len())
108+
109+ m.Del("key2")
110+ assert.Equal(t, 0, m.Len())
111+}
112+
113+func TestMap_Seq2(t *testing.T) {
114+ t.Parallel()
115+
116+ m := NewMap[string, int]()
117+ m.Set("key1", 1)
118+ m.Set("key2", 2)
119+ m.Set("key3", 3)
120+
121+ collected := maps.Collect(m.Seq2())
122+
123+ assert.Equal(t, 3, len(collected))
124+ assert.Equal(t, 1, collected["key1"])
125+ assert.Equal(t, 2, collected["key2"])
126+ assert.Equal(t, 3, collected["key3"])
127+}
128+
129+func TestMap_Seq2_EarlyReturn(t *testing.T) {
130+ t.Parallel()
131+
132+ m := NewMap[string, int]()
133+ m.Set("key1", 1)
134+ m.Set("key2", 2)
135+ m.Set("key3", 3)
136+
137+ count := 0
138+ for range m.Seq2() {
139+ count++
140+ if count == 2 {
141+ break
142+ }
143+ }
144+
145+ assert.Equal(t, 2, count)
146+}
147+
148+func TestMap_Seq2_EmptyMap(t *testing.T) {
149+ t.Parallel()
150+
151+ m := NewMap[string, int]()
152+
153+ count := 0
154+ for range m.Seq2() {
155+ count++
156+ }
157+
158+ assert.Equal(t, 0, count)
159+}
160+
161+func TestMap_MarshalJSON(t *testing.T) {
162+ t.Parallel()
163+
164+ m := NewMap[string, int]()
165+ m.Set("key1", 1)
166+ m.Set("key2", 2)
167+
168+ data, err := json.Marshal(m)
169+ assert.NoError(t, err)
170+
171+ result := &Map[string, int]{}
172+ err = json.Unmarshal(data, result)
173+ assert.NoError(t, err)
174+ assert.Equal(t, 2, result.Len())
175+ v1, _ := result.Get("key1")
176+ v2, _ := result.Get("key2")
177+ assert.Equal(t, 1, v1)
178+ assert.Equal(t, 2, v2)
179+}
180+
181+func TestMap_MarshalJSON_EmptyMap(t *testing.T) {
182+ t.Parallel()
183+
184+ m := NewMap[string, int]()
185+
186+ data, err := json.Marshal(m)
187+ assert.NoError(t, err)
188+ assert.Equal(t, "{}", string(data))
189+}
190+
191+func TestMap_UnmarshalJSON(t *testing.T) {
192+ t.Parallel()
193+
194+ jsonData := `{"key1": 1, "key2": 2}`
195+
196+ m := NewMap[string, int]()
197+ err := json.Unmarshal([]byte(jsonData), m)
198+ assert.NoError(t, err)
199+
200+ assert.Equal(t, 2, m.Len())
201+ value, ok := m.Get("key1")
202+ assert.True(t, ok)
203+ assert.Equal(t, 1, value)
204+
205+ value, ok = m.Get("key2")
206+ assert.True(t, ok)
207+ assert.Equal(t, 2, value)
208+}
209+
210+func TestMap_UnmarshalJSON_EmptyJSON(t *testing.T) {
211+ t.Parallel()
212+
213+ jsonData := `{}`
214+
215+ m := NewMap[string, int]()
216+ err := json.Unmarshal([]byte(jsonData), m)
217+ assert.NoError(t, err)
218+ assert.Equal(t, 0, m.Len())
219+}
220+
221+func TestMap_UnmarshalJSON_InvalidJSON(t *testing.T) {
222+ t.Parallel()
223+
224+ jsonData := `{"key1": 1, "key2":}`
225+
226+ m := NewMap[string, int]()
227+ err := json.Unmarshal([]byte(jsonData), m)
228+ assert.Error(t, err)
229+}
230+
231+func TestMap_UnmarshalJSON_OverwritesExistingData(t *testing.T) {
232+ t.Parallel()
233+
234+ m := NewMap[string, int]()
235+ m.Set("existing", 999)
236+
237+ jsonData := `{"key1": 1, "key2": 2}`
238+ err := json.Unmarshal([]byte(jsonData), m)
239+ assert.NoError(t, err)
240+
241+ assert.Equal(t, 2, m.Len())
242+ _, ok := m.Get("existing")
243+ assert.False(t, ok)
244+
245+ value, ok := m.Get("key1")
246+ assert.True(t, ok)
247+ assert.Equal(t, 1, value)
248+}
249+
250+func TestMap_JSONRoundTrip(t *testing.T) {
251+ t.Parallel()
252+
253+ original := NewMap[string, int]()
254+ original.Set("key1", 1)
255+ original.Set("key2", 2)
256+ original.Set("key3", 3)
257+
258+ data, err := json.Marshal(original)
259+ assert.NoError(t, err)
260+
261+ restored := NewMap[string, int]()
262+ err = json.Unmarshal(data, restored)
263+ assert.NoError(t, err)
264+
265+ assert.Equal(t, original.Len(), restored.Len())
266+
267+ for k, v := range original.Seq2() {
268+ restoredValue, ok := restored.Get(k)
269+ assert.True(t, ok)
270+ assert.Equal(t, v, restoredValue)
271+ }
272+}
273+
274+func TestMap_ConcurrentAccess(t *testing.T) {
275+ t.Parallel()
276+
277+ m := NewMap[int, int]()
278+ const numGoroutines = 100
279+ const numOperations = 100
280+
281+ var wg sync.WaitGroup
282+ wg.Add(numGoroutines)
283+
284+ for i := range numGoroutines {
285+ go func(id int) {
286+ defer wg.Done()
287+ for j := range numOperations {
288+ key := id*numOperations + j
289+ m.Set(key, key*2)
290+ value, ok := m.Get(key)
291+ assert.True(t, ok)
292+ assert.Equal(t, key*2, value)
293+ }
294+ }(i)
295+ }
296+
297+ wg.Wait()
298+
299+ assert.Equal(t, numGoroutines*numOperations, m.Len())
300+}
301+
302+func TestMap_ConcurrentReadWrite(t *testing.T) {
303+ t.Parallel()
304+
305+ m := NewMap[int, int]()
306+ const numReaders = 50
307+ const numWriters = 50
308+ const numOperations = 100
309+
310+ for i := range 1000 {
311+ m.Set(i, i)
312+ }
313+
314+ var wg sync.WaitGroup
315+ wg.Add(numReaders + numWriters)
316+
317+ for range numReaders {
318+ go func() {
319+ defer wg.Done()
320+ for j := range numOperations {
321+ key := j % 1000
322+ value, ok := m.Get(key)
323+ if ok {
324+ assert.Equal(t, key, value)
325+ }
326+ _ = m.Len()
327+ }
328+ }()
329+ }
330+
331+ for i := range numWriters {
332+ go func(id int) {
333+ defer wg.Done()
334+ for j := range numOperations {
335+ key := 1000 + id*numOperations + j
336+ m.Set(key, key)
337+ if j%10 == 0 {
338+ m.Del(key)
339+ }
340+ }
341+ }(i)
342+ }
343+
344+ wg.Wait()
345+}
346+
347+func TestMap_ConcurrentSeq2(t *testing.T) {
348+ t.Parallel()
349+
350+ m := NewMap[int, int]()
351+ for i := range 100 {
352+ m.Set(i, i*2)
353+ }
354+
355+ var wg sync.WaitGroup
356+ const numIterators = 10
357+
358+ wg.Add(numIterators)
359+ for range numIterators {
360+ go func() {
361+ defer wg.Done()
362+ count := 0
363+ for k, v := range m.Seq2() {
364+ assert.Equal(t, k*2, v)
365+ count++
366+ }
367+ assert.Equal(t, 100, count)
368+ }()
369+ }
370+
371+ wg.Wait()
372+}
373+
374+func TestMap_TypeSafety(t *testing.T) {
375+ t.Parallel()
376+
377+ stringIntMap := NewMap[string, int]()
378+ stringIntMap.Set("key", 42)
379+ value, ok := stringIntMap.Get("key")
380+ assert.True(t, ok)
381+ assert.Equal(t, 42, value)
382+
383+ intStringMap := NewMap[int, string]()
384+ intStringMap.Set(42, "value")
385+ strValue, ok := intStringMap.Get(42)
386+ assert.True(t, ok)
387+ assert.Equal(t, "value", strValue)
388+
389+ structMap := NewMap[string, struct{ Name string }]()
390+ structMap.Set("key", struct{ Name string }{Name: "test"})
391+ structValue, ok := structMap.Get("key")
392+ assert.True(t, ok)
393+ assert.Equal(t, "test", structValue.Name)
394+}
395+
396+func TestMap_InterfaceCompliance(t *testing.T) {
397+ t.Parallel()
398+
399+ var _ json.Marshaler = &Map[string, any]{}
400+ var _ json.Unmarshaler = &Map[string, any]{}
401+}
402+
403+func BenchmarkMap_Set(b *testing.B) {
404+ m := NewMap[int, int]()
405+
406+ for i := 0; b.Loop(); i++ {
407+ m.Set(i, i*2)
408+ }
409+}
410+
411+func BenchmarkMap_Get(b *testing.B) {
412+ m := NewMap[int, int]()
413+ for i := range 1000 {
414+ m.Set(i, i*2)
415+ }
416+
417+ for i := 0; b.Loop(); i++ {
418+ m.Get(i % 1000)
419+ }
420+}
421+
422+func BenchmarkMap_Seq2(b *testing.B) {
423+ m := NewMap[int, int]()
424+ for i := range 1000 {
425+ m.Set(i, i*2)
426+ }
427+
428+ for b.Loop() {
429+ for range m.Seq2() {
430+ }
431+ }
432+}
433+
434+func BenchmarkMap_ConcurrentReadWrite(b *testing.B) {
435+ m := NewMap[int, int]()
436+ for i := range 1000 {
437+ m.Set(i, i*2)
438+ }
439+
440+ b.ResetTimer()
441+ b.RunParallel(func(pb *testing.PB) {
442+ i := 0
443+ for pb.Next() {
444+ if i%2 == 0 {
445+ m.Get(i % 1000)
446+ } else {
447+ m.Set(i+1000, i*2)
448+ }
449+ i++
450+ }
451+ })
452+}
internal/csync/slices.gonew file+36
@@ -0,0 +1,36 @@
1+package csync
2+
3+import (
4+ "iter"
5+ "sync"
6+)
7+
8+// LazySlice is a thread-safe lazy-loaded slice.
9+type LazySlice[K any] struct {
10+ inner []K
11+ wg sync.WaitGroup
12+}
13+
14+// NewLazySlice creates a new slice and runs the [load] function in a goroutine
15+// to populate it.
16+func NewLazySlice[K any](load func() []K) *LazySlice[K] {
17+ s := &LazySlice[K]{}
18+ s.wg.Add(1)
19+ go func() {
20+ s.inner = load()
21+ s.wg.Done()
22+ }()
23+ return s
24+}
25+
26+// Seq returns an iterator that yields elements from the slice.
27+func (s *LazySlice[K]) Seq() iter.Seq[K] {
28+ s.wg.Wait()
29+ return func(yield func(K) bool) {
30+ for _, v := range s.inner {
31+ if !yield(v) {
32+ return
33+ }
34+ }
35+ }
36+}
internal/csync/slices_test.gonew file+87
@@ -0,0 +1,87 @@
1+package csync
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestLazySlice_Seq(t *testing.T) {
12+ t.Parallel()
13+
14+ data := []string{"a", "b", "c"}
15+ s := NewLazySlice(func() []string {
16+ // TODO: use synctest when new Go is out.
17+ time.Sleep(10 * time.Millisecond) // Small delay to ensure loading happens
18+ return data
19+ })
20+
21+ var result []string
22+ for v := range s.Seq() {
23+ result = append(result, v)
24+ }
25+
26+ assert.Equal(t, data, result)
27+}
28+
29+func TestLazySlice_SeqWaitsForLoading(t *testing.T) {
30+ t.Parallel()
31+
32+ var loaded atomic.Bool
33+ data := []string{"x", "y", "z"}
34+
35+ s := NewLazySlice(func() []string {
36+ // TODO: use synctest when new Go is out.
37+ time.Sleep(100 * time.Millisecond)
38+ loaded.Store(true)
39+ return data
40+ })
41+
42+ assert.False(t, loaded.Load(), "should not be loaded immediately")
43+
44+ var result []string
45+ for v := range s.Seq() {
46+ result = append(result, v)
47+ }
48+
49+ assert.True(t, loaded.Load(), "should be loaded after Seq")
50+ assert.Equal(t, data, result)
51+}
52+
53+func TestLazySlice_EmptySlice(t *testing.T) {
54+ t.Parallel()
55+
56+ s := NewLazySlice(func() []string {
57+ return []string{}
58+ })
59+
60+ var result []string
61+ for v := range s.Seq() {
62+ result = append(result, v)
63+ }
64+
65+ assert.Empty(t, result)
66+}
67+
68+func TestLazySlice_EarlyBreak(t *testing.T) {
69+ t.Parallel()
70+
71+ data := []string{"a", "b", "c", "d", "e"}
72+ s := NewLazySlice(func() []string {
73+ // TODO: use synctest when new Go is out.
74+ time.Sleep(10 * time.Millisecond) // Small delay to ensure loading happens
75+ return data
76+ })
77+
78+ var result []string
79+ for v := range s.Seq() {
80+ result = append(result, v)
81+ if len(result) == 2 {
82+ break
83+ }
84+ }
85+
86+ assert.Equal(t, []string{"a", "b"}, result)
87+}
internal/llm/agent/agent.go+51−43
@@ -12,6 +12,7 @@ import (
1212
1313 "github.com/charmbracelet/catwalk/pkg/catwalk"
1414 "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/csync"
1516 "github.com/charmbracelet/crush/internal/history"
1617 "github.com/charmbracelet/crush/internal/llm/prompt"
1718 "github.com/charmbracelet/crush/internal/llm/provider"
@@ -68,7 +69,8 @@ type agent struct {
6869 sessions session.Service
6970 messages message.Service
7071
71− tools []tools.BaseTool
72+ tools *csync.LazySlice[tools.BaseTool]
73+
7274 provider provider.Provider
7375 providerID string
7476
@@ -95,25 +97,8 @@ func NewAgent(
9597) (Service, error) {
9698 ctx := context.Background()
9799 cfg := config.Get()
98− otherTools := GetMCPTools(ctx, permissions, cfg)
99− if len(lspClients) > 0 {
100− otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
101− }
102−
103− cwd := cfg.WorkingDir()
104− allTools := []tools.BaseTool{
105− tools.NewBashTool(permissions, cwd),
106− tools.NewDownloadTool(permissions, cwd),
107− tools.NewEditTool(lspClients, permissions, history, cwd),
108− tools.NewFetchTool(permissions, cwd),
109− tools.NewGlobTool(cwd),
110− tools.NewGrepTool(cwd),
111− tools.NewLsTool(cwd),
112− tools.NewSourcegraphTool(),
113− tools.NewViewTool(lspClients, cwd),
114− tools.NewWriteTool(lspClients, permissions, history, cwd),
115− }
116100
101+ var agentTool tools.BaseTool
117102 if agentCfg.ID == "coder" {
118103 taskAgentCfg := config.Get().Agents["task"]
119104 if taskAgentCfg.ID == "" {
@@ -124,17 +109,9 @@ func NewAgent(
124109 return nil, fmt.Errorf("failed to create task agent: %w", err)
125110 }
126111
127− allTools = append(
128− allTools,
129− NewAgentTool(
130− taskAgent,
131− sessions,
132− messages,
133− ),
134− )
112+ agentTool = NewAgentTool(taskAgent, sessions, messages)
135113 }
136114
137− allTools = append(allTools, otherTools...)
138115 providerCfg := config.Get().GetProviderForModel(agentCfg.Model)
139116 if providerCfg == nil {
140117 return nil, fmt.Errorf("provider for agent %s not found in config", agentCfg.Name)
@@ -191,32 +168,63 @@ func NewAgent(
191168 return nil, err
192169 }
193170
194− agentTools := []tools.BaseTool{}
195− if agentCfg.AllowedTools == nil {
196− agentTools = allTools
197− } else {
171+ toolFn := func() []tools.BaseTool {
172+ slog.Info("Initializing agent tools", "agent", agentCfg.ID)
173+ defer func() {
174+ slog.Info("Initialized agent tools", "agent", agentCfg.ID)
175+ }()
176+
177+ cwd := cfg.WorkingDir()
178+ allTools := []tools.BaseTool{
179+ tools.NewBashTool(permissions, cwd),
180+ tools.NewDownloadTool(permissions, cwd),
181+ tools.NewEditTool(lspClients, permissions, history, cwd),
182+ tools.NewFetchTool(permissions, cwd),
183+ tools.NewGlobTool(cwd),
184+ tools.NewGrepTool(cwd),
185+ tools.NewLsTool(cwd),
186+ tools.NewSourcegraphTool(),
187+ tools.NewViewTool(lspClients, cwd),
188+ tools.NewWriteTool(lspClients, permissions, history, cwd),
189+ }
190+
191+ mcpTools := GetMCPTools(ctx, permissions, cfg)
192+ allTools = append(allTools, mcpTools...)
193+
194+ if len(lspClients) > 0 {
195+ allTools = append(allTools, tools.NewDiagnosticsTool(lspClients))
196+ }
197+
198+ if agentTool != nil {
199+ allTools = append(allTools, agentTool)
200+ }
201+
202+ if agentCfg.AllowedTools == nil {
203+ return allTools
204+ }
205+
206+ var filteredTools []tools.BaseTool
198207 for _, tool := range allTools {
199208 if slices.Contains(agentCfg.AllowedTools, tool.Name()) {
200− agentTools = append(agentTools, tool)
209+ filteredTools = append(filteredTools, tool)
201210 }
202211 }
212+ return filteredTools
203213 }
204214
205− agent := &agent{
215+ return &agent{
206216 Broker: pubsub.NewBroker[AgentEvent](),
207217 agentCfg: agentCfg,
208218 provider: agentProvider,
209219 providerID: string(providerCfg.ID),
210220 messages: messages,
211221 sessions: sessions,
212− tools: agentTools,
213222 titleProvider: titleProvider,
214223 summarizeProvider: summarizeProvider,
215224 summarizeProviderID: string(smallModelProviderCfg.ID),
216225 activeRequests: sync.Map{},
217− }
218−
219− return agent, nil
226+ tools: csync.NewLazySlice(toolFn),
227+ }, nil
220228}
221229
222230func (a *agent) Model() catwalk.Model {
@@ -284,7 +292,7 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
284292 Parts: parts,
285293 },
286294 },
287− make([]tools.BaseTool, 0),
295+ nil,
288296 )
289297
290298 var finalResponse *provider.ProviderResponse
@@ -438,7 +446,7 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
438446
439447func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
440448 ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
441− eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
449+ eventChan := a.provider.StreamResponse(ctx, msgHistory, slices.Collect(a.tools.Seq()))
442450
443451 assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
444452 Role: message.Assistant,
@@ -487,7 +495,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
487495 default:
488496 // Continue processing
489497 var tool tools.BaseTool
490− for _, availableTool := range a.tools {
498+ for availableTool := range a.tools.Seq() {
491499 if availableTool.Info().Name == toolCall.Name {
492500 tool = availableTool
493501 break
@@ -737,7 +745,7 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
737745 response := a.summarizeProvider.StreamResponse(
738746 summarizeCtx,
739747 msgsWithPrompt,
740− make([]tools.BaseTool, 0),
748+ nil,
741749 )
742750 var finalResponse *provider.ProviderResponse
743751 for r := range response {
@@ -899,7 +907,7 @@ func (a *agent) UpdateModel() error {
899907 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
900908 var smallModelProviderCfg config.ProviderConfig
901909
902− for _, p := range cfg.Providers {
910+ for _, p := range cfg.Providers.Seq2() {
903911 if p.ID == smallModelCfg.Provider {
904912 smallModelProviderCfg = p
905913 break
internal/llm/provider/gemini.go+2−6
@@ -188,9 +188,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
188188 Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
189189 },
190190 }
191− if len(tools) > 0 {
192− config.Tools = g.convertTools(tools)
193− }
191+ config.Tools = g.convertTools(tools)
194192 chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
195193
196194 attempts := 0
@@ -290,9 +288,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
290288 Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
291289 },
292290 }
293− if len(tools) > 0 {
294− config.Tools = g.convertTools(tools)
295− }
291+ config.Tools = g.convertTools(tools)
296292 chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
297293
298294 attempts := 0
internal/llm/provider/openai_test.go+1−2
@@ -11,7 +11,6 @@ import (
1111
1212 "github.com/charmbracelet/catwalk/pkg/catwalk"
1313 "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/llm/tools"
1514 "github.com/charmbracelet/crush/internal/message"
1615 "github.com/openai/openai-go"
1716 "github.com/openai/openai-go/option"
@@ -79,7 +78,7 @@ func TestOpenAIClientStreamChoices(t *testing.T) {
7978 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
8079 defer cancel()
8180
82− eventsChan := client.stream(ctx, messages, []tools.BaseTool{})
81+ eventsChan := client.stream(ctx, messages, nil)
8382
8483 // Collect events - this will panic without the bounds check
8584 for event := range eventsChan {
internal/tui/components/chat/splash/splash.go+1−1
@@ -425,7 +425,7 @@ func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.
425425
426426func (s *splashCmp) isProviderConfigured(providerID string) bool {
427427 cfg := config.Get()
428− if _, ok := cfg.Providers[providerID]; ok {
428+ if _, ok := cfg.Providers.Get(providerID); ok {
429429 return true
430430 }
431431 return false
internal/tui/components/dialogs/models/list.go+3−3
@@ -103,7 +103,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
103103 if err != nil {
104104 return util.ReportError(err)
105105 }
106− for providerID, providerConfig := range cfg.Providers {
106+ for providerID, providerConfig := range cfg.Providers.Seq2() {
107107 if providerConfig.Disable {
108108 continue
109109 }
@@ -164,7 +164,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
164164 }
165165
166166 // Check if this provider is configured and not disabled
167− if providerConfig, exists := cfg.Providers[string(provider.ID)]; exists && providerConfig.Disable {
167+ if providerConfig, exists := cfg.Providers.Get(string(provider.ID)); exists && providerConfig.Disable {
168168 continue
169169 }
170170
@@ -174,7 +174,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
174174 }
175175
176176 section := commands.NewItemSection(name)
177− if _, ok := cfg.Providers[string(provider.ID)]; ok {
177+ if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
178178 section.SetInfo(configured)
179179 }
180180 modelItems = append(modelItems, section)
internal/tui/components/dialogs/models/models.go+1−1
@@ -357,7 +357,7 @@ func (m *modelDialogCmp) modelTypeRadio() string {
357357
358358func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
359359 cfg := config.Get()
360− if _, ok := cfg.Providers[providerID]; ok {
360+ if _, ok := cfg.Providers.Get(providerID); ok {
361361 return true
362362 }
363363 return false