load_test.go

   1package config
   2
   3import (
   4	"io"
   5	"log/slog"
   6	"os"
   7	"os/exec"
   8	"path/filepath"
   9	"testing"
  10
  11	"charm.land/catwalk/pkg/catwalk"
  12	"github.com/charmbracelet/crush/internal/csync"
  13	"github.com/charmbracelet/crush/internal/env"
  14	"github.com/stretchr/testify/assert"
  15	"github.com/stretchr/testify/require"
  16)
  17
  18func TestMain(m *testing.M) {
  19	slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
  20
  21	exitVal := m.Run()
  22	os.Exit(exitVal)
  23}
  24
  25func TestConfig_LoadFromBytes(t *testing.T) {
  26	data1 := []byte(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
  27	data2 := []byte(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
  28	data3 := []byte(`{"providers": {"openai": {}}}`)
  29
  30	loadedConfig, err := loadFromBytes([][]byte{data1, data2, data3})
  31
  32	require.NoError(t, err)
  33	require.NotNil(t, loadedConfig)
  34	require.Equal(t, 1, loadedConfig.Providers.Len())
  35	pc, _ := loadedConfig.Providers.Get("openai")
  36	require.Equal(t, "key2", pc.APIKey)
  37	require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
  38}
  39
  40func TestLookupConfigs_BoundedByProject(t *testing.T) {
  41	// Force GlobalConfig and GlobalConfigData to point at locations we
  42	// control so they can be present in the result without polluting
  43	// the developer's real config.
  44	globalDir := t.TempDir()
  45	t.Setenv("CRUSH_GLOBAL_CONFIG", globalDir)
  46	t.Setenv("CRUSH_GLOBAL_DATA", globalDir)
  47
  48	t.Run("does not pick up crush.json above non-git project", func(t *testing.T) {
  49		parent := t.TempDir()
  50
  51		// crush.json above the project must not be adopted.
  52		require.NoError(t, os.WriteFile(
  53			filepath.Join(parent, "crush.json"),
  54			[]byte(`{}`),
  55			0o644,
  56		))
  57
  58		project := filepath.Join(parent, "project")
  59		require.NoError(t, os.Mkdir(project, 0o755))
  60
  61		got := lookupConfigs(project)
  62		for _, p := range got {
  63			require.NotEqual(t, filepath.Join(parent, "crush.json"), p)
  64		}
  65	})
  66
  67	t.Run("does not climb out of git worktree to find crush.json", func(t *testing.T) {
  68		if _, err := exec.LookPath("git"); err != nil {
  69			t.Skip("git not available")
  70		}
  71
  72		parent := t.TempDir()
  73
  74		require.NoError(t, os.WriteFile(
  75			filepath.Join(parent, "crush.json"),
  76			[]byte(`{}`),
  77			0o644,
  78		))
  79
  80		worktree := filepath.Join(parent, "worktree")
  81		require.NoError(t, os.Mkdir(worktree, 0o755))
  82		gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
  83		gitInit.Dir = worktree
  84		require.NoError(t, gitInit.Run())
  85
  86		got := lookupConfigs(worktree)
  87		strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, "crush.json"))
  88		require.NoError(t, err)
  89		for _, p := range got {
  90			pEval, err := filepath.EvalSymlinks(p)
  91			if err != nil {
  92				continue
  93			}
  94			require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json")
  95		}
  96	})
  97
  98	t.Run("picks up crush.json inside the project", func(t *testing.T) {
  99		project := t.TempDir()
 100		local := filepath.Join(project, "crush.json")
 101		require.NoError(t, os.WriteFile(local, []byte(`{}`), 0o644))
 102
 103		got := lookupConfigs(project)
 104
 105		localEval, err := filepath.EvalSymlinks(local)
 106		require.NoError(t, err)
 107		var foundLocal bool
 108		for _, p := range got {
 109			pEval, err := filepath.EvalSymlinks(p)
 110			if err != nil {
 111				continue
 112			}
 113			if pEval == localEval {
 114				foundLocal = true
 115				break
 116			}
 117		}
 118		require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got)
 119	})
 120
 121	t.Run("global config is always included regardless of boundary", func(t *testing.T) {
 122		project := t.TempDir()
 123
 124		got := lookupConfigs(project)
 125		// Global config and global data path are always prepended,
 126		// even when no project file exists.
 127		require.Contains(t, got, GlobalConfig())
 128		require.Contains(t, got, GlobalConfigData())
 129	})
 130}
 131
 132func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
 133	t.Parallel()
 134
 135	t.Run("identifies the offending file", func(t *testing.T) {
 136		t.Parallel()
 137		tmpDir := t.TempDir()
 138		good := filepath.Join(tmpDir, "good.json")
 139		bad := filepath.Join(tmpDir, "bad.json")
 140		require.NoError(t, os.WriteFile(good, []byte(`{"providers":{}}`), 0o644))
 141		require.NoError(t, os.WriteFile(bad, []byte(`{not valid json}`), 0o644))
 142
 143		_, _, err := loadFromConfigPaths([]string{good, bad})
 144		require.Error(t, err)
 145		require.Contains(t, err.Error(), "invalid JSON in config file")
 146		require.Contains(t, err.Error(), "bad.json")
 147	})
 148
 149	t.Run("skips missing and empty files", func(t *testing.T) {
 150		t.Parallel()
 151		tmpDir := t.TempDir()
 152		empty := filepath.Join(tmpDir, "empty.json")
 153		require.NoError(t, os.WriteFile(empty, []byte(""), 0o644))
 154
 155		cfg, _, err := loadFromConfigPaths([]string{
 156			filepath.Join(tmpDir, "nonexistent.json"),
 157			empty,
 158		})
 159		require.NoError(t, err)
 160		require.NotNil(t, cfg)
 161	})
 162}
 163
 164// testStore wraps a Config in a minimal ConfigStore for testing.
 165func testStore(cfg *Config) *ConfigStore {
 166	return &ConfigStore{config: cfg}
 167}
 168
 169func TestConfig_setDefaults(t *testing.T) {
 170	t.Run("sets default data directory", func(t *testing.T) {
 171		cfg := &Config{}
 172		workingDir := t.TempDir()
 173
 174		cfg.setDefaults(workingDir, "")
 175
 176		require.NotNil(t, cfg.Options)
 177		require.NotNil(t, cfg.Options.TUI)
 178		require.NotNil(t, cfg.Options.ContextPaths)
 179		require.NotNil(t, cfg.Providers)
 180		require.NotNil(t, cfg.Models)
 181		require.NotNil(t, cfg.LSP)
 182		require.NotNil(t, cfg.MCP)
 183		require.Equal(t, filepath.Join(workingDir, ".crush"), cfg.Options.DataDirectory)
 184		require.Equal(t, "AGENTS.md", cfg.Options.InitializeAs)
 185		for _, path := range defaultContextPaths {
 186			require.Contains(t, cfg.Options.ContextPaths, path)
 187		}
 188	})
 189
 190	t.Run("resolves relative configured data directory from working directory", func(t *testing.T) {
 191		cfg := &Config{Options: &Options{DataDirectory: "."}}
 192		workingDir := filepath.Join(t.TempDir(), "worktree")
 193
 194		cfg.setDefaults(workingDir, "")
 195
 196		require.Equal(t, workingDir, cfg.Options.DataDirectory)
 197	})
 198
 199	t.Run("resolves relative flag data directory from working directory", func(t *testing.T) {
 200		cfg := &Config{}
 201		workingDir := filepath.Join(t.TempDir(), "worktree")
 202
 203		cfg.setDefaults(workingDir, "./state")
 204
 205		require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
 206	})
 207
 208	t.Run("preserves absolute configured data directory", func(t *testing.T) {
 209		// Use a platform-appropriate absolute path so the test runs
 210		// the same way on POSIX and Windows.
 211		absDir := filepath.Join(t.TempDir(), "data")
 212		cfg := &Config{Options: &Options{DataDirectory: absDir}}
 213
 214		cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "")
 215
 216		require.Equal(t, absDir, cfg.Options.DataDirectory)
 217	})
 218
 219	t.Run("workspace merge re-entry keeps an absolute data directory", func(t *testing.T) {
 220		// Simulate the load and reload paths: defaults are applied
 221		// twice with the data directory potentially carried through
 222		// from an earlier merge as a relative string.
 223		workingDir := filepath.Join(t.TempDir(), "worktree")
 224		cfg := &Config{}
 225		cfg.setDefaults(workingDir, "")
 226
 227		// Workspace JSON sets data_directory to a relative value; the
 228		// merge replaces the struct, then setDefaults runs again.
 229		cfg.Options.DataDirectory = "./state"
 230		cfg.setDefaults(workingDir, "")
 231
 232		require.True(t, filepath.IsAbs(cfg.Options.DataDirectory),
 233			"data directory must remain absolute after re-merge, got %q",
 234			cfg.Options.DataDirectory)
 235		require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
 236	})
 237
 238	t.Run("does not adopt .crush from a parent project", func(t *testing.T) {
 239		parent := t.TempDir()
 240
 241		// .crush in the parent: it should not be reused by the child
 242		// because there is no git context joining them.
 243		require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
 244
 245		child := filepath.Join(parent, "child")
 246		require.NoError(t, os.Mkdir(child, 0o755))
 247
 248		cfg := &Config{}
 249		cfg.setDefaults(child, "")
 250
 251		require.Equal(
 252			t,
 253			filepath.Clean(filepath.Join(child, defaultDataDirectory)),
 254			filepath.Clean(cfg.Options.DataDirectory),
 255		)
 256	})
 257
 258	t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) {
 259		if _, err := exec.LookPath("git"); err != nil {
 260			t.Skip("git not available")
 261		}
 262
 263		parent := t.TempDir()
 264
 265		// Stray .crush above the worktree root.
 266		require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
 267
 268		worktree := filepath.Join(parent, "worktree")
 269		require.NoError(t, os.Mkdir(worktree, 0o755))
 270
 271		sub := filepath.Join(worktree, "pkg")
 272		require.NoError(t, os.Mkdir(sub, 0o755))
 273
 274		// Make worktree a real git repo so the boundary detection
 275		// resolves to it, mirroring what happens with linked worktrees
 276		// in real usage.
 277		gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
 278		gitInit.Dir = worktree
 279		require.NoError(t, gitInit.Run())
 280
 281		cfg := &Config{}
 282		cfg.setDefaults(sub, "")
 283
 284		// Resolve symlinks because TempDir on macOS sits under /var
 285		// which is a symlink to /private/var. The data directory has
 286		// not been created yet, so resolve its parent and join.
 287		gotDir, gotName := filepath.Split(cfg.Options.DataDirectory)
 288		gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir))
 289		require.NoError(t, err)
 290		gotEval := filepath.Join(gotEvalDir, gotName)
 291
 292		strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory))
 293		require.NoError(t, err)
 294		require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush")
 295
 296		subEval, err := filepath.EvalSymlinks(sub)
 297		require.NoError(t, err)
 298		require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval)
 299	})
 300}
 301
 302func TestConfig_configureProviders(t *testing.T) {
 303	knownProviders := []catwalk.Provider{
 304		{
 305			ID:          "openai",
 306			APIKey:      "$OPENAI_API_KEY",
 307			APIEndpoint: "https://api.openai.com/v1",
 308			Models: []catwalk.Model{{
 309				ID: "test-model",
 310			}},
 311		},
 312	}
 313
 314	cfg := &Config{}
 315	cfg.setDefaults("/tmp", "")
 316	env := env.NewFromMap(map[string]string{
 317		"OPENAI_API_KEY": "test-key",
 318	})
 319	resolver := NewShellVariableResolver(env)
 320	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 321	require.NoError(t, err)
 322	require.Equal(t, 1, cfg.Providers.Len())
 323
 324	// We want to make sure that we keep the configured API key as a placeholder
 325	pc, _ := cfg.Providers.Get("openai")
 326	require.Equal(t, "$OPENAI_API_KEY", pc.APIKey)
 327}
 328
 329func TestConfig_configureProvidersWithOverride(t *testing.T) {
 330	knownProviders := []catwalk.Provider{
 331		{
 332			ID:          "openai",
 333			APIKey:      "$OPENAI_API_KEY",
 334			APIEndpoint: "https://api.openai.com/v1",
 335			Models: []catwalk.Model{{
 336				ID: "test-model",
 337			}},
 338		},
 339	}
 340
 341	cfg := &Config{
 342		Providers: csync.NewMap[string, ProviderConfig](),
 343	}
 344	cfg.Providers.Set("openai", ProviderConfig{
 345		APIKey:  "xyz",
 346		BaseURL: "https://api.openai.com/v2",
 347		Models: []catwalk.Model{
 348			{
 349				ID:   "test-model",
 350				Name: "Updated",
 351			},
 352			{
 353				ID: "another-model",
 354			},
 355		},
 356	})
 357	cfg.setDefaults("/tmp", "")
 358
 359	env := env.NewFromMap(map[string]string{
 360		"OPENAI_API_KEY": "test-key",
 361	})
 362	resolver := NewShellVariableResolver(env)
 363	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 364	require.NoError(t, err)
 365	require.Equal(t, 1, cfg.Providers.Len())
 366
 367	// We want to make sure that we keep the configured API key as a placeholder
 368	pc, _ := cfg.Providers.Get("openai")
 369	require.Equal(t, "xyz", pc.APIKey)
 370	require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
 371	require.Len(t, pc.Models, 2)
 372	require.Equal(t, "Updated", pc.Models[0].Name)
 373}
 374
 375func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
 376	knownProviders := []catwalk.Provider{
 377		{
 378			ID:          "openai",
 379			APIKey:      "$OPENAI_API_KEY",
 380			APIEndpoint: "https://api.openai.com/v1",
 381			Models: []catwalk.Model{{
 382				ID: "test-model",
 383			}},
 384		},
 385	}
 386
 387	cfg := &Config{
 388		Providers: csync.NewMapFrom(map[string]ProviderConfig{
 389			"custom": {
 390				APIKey:  "xyz",
 391				BaseURL: "https://api.someendpoint.com/v2",
 392				Models: []catwalk.Model{
 393					{
 394						ID: "test-model",
 395					},
 396				},
 397			},
 398		}),
 399	}
 400	cfg.setDefaults("/tmp", "")
 401	env := env.NewFromMap(map[string]string{
 402		"OPENAI_API_KEY": "test-key",
 403	})
 404	resolver := NewShellVariableResolver(env)
 405	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 406	require.NoError(t, err)
 407	// Should be to because of the env variable
 408	require.Equal(t, cfg.Providers.Len(), 2)
 409
 410	// We want to make sure that we keep the configured API key as a placeholder
 411	pc, _ := cfg.Providers.Get("custom")
 412	require.Equal(t, "xyz", pc.APIKey)
 413	// Make sure we set the ID correctly
 414	require.Equal(t, "custom", pc.ID)
 415	require.Equal(t, "https://api.someendpoint.com/v2", pc.BaseURL)
 416	require.Len(t, pc.Models, 1)
 417
 418	_, ok := cfg.Providers.Get("openai")
 419	require.True(t, ok, "OpenAI provider should still be present")
 420}
 421
 422func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
 423	knownProviders := []catwalk.Provider{
 424		{
 425			ID:          catwalk.InferenceProviderBedrock,
 426			APIKey:      "",
 427			APIEndpoint: "",
 428			Models: []catwalk.Model{{
 429				ID: "anthropic.claude-sonnet-4-20250514-v1:0",
 430			}},
 431		},
 432	}
 433
 434	cfg := &Config{}
 435	cfg.setDefaults("/tmp", "")
 436	env := env.NewFromMap(map[string]string{
 437		"AWS_ACCESS_KEY_ID":     "test-key-id",
 438		"AWS_SECRET_ACCESS_KEY": "test-secret-key",
 439	})
 440	resolver := NewShellVariableResolver(env)
 441	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 442	require.NoError(t, err)
 443	require.Equal(t, cfg.Providers.Len(), 1)
 444
 445	bedrockProvider, ok := cfg.Providers.Get("bedrock")
 446	require.True(t, ok, "Bedrock provider should be present")
 447	require.Len(t, bedrockProvider.Models, 1)
 448	require.Equal(t, "anthropic.claude-sonnet-4-20250514-v1:0", bedrockProvider.Models[0].ID)
 449}
 450
 451func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
 452	knownProviders := []catwalk.Provider{
 453		{
 454			ID:          catwalk.InferenceProviderBedrock,
 455			APIKey:      "",
 456			APIEndpoint: "",
 457			Models: []catwalk.Model{{
 458				ID: "anthropic.claude-sonnet-4-20250514-v1:0",
 459			}},
 460		},
 461	}
 462
 463	cfg := &Config{}
 464	cfg.setDefaults("/tmp", "")
 465	env := env.NewFromMap(map[string]string{})
 466	resolver := NewShellVariableResolver(env)
 467	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 468	require.NoError(t, err)
 469	// Provider should not be configured without credentials
 470	require.Equal(t, cfg.Providers.Len(), 0)
 471}
 472
 473func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
 474	knownProviders := []catwalk.Provider{
 475		{
 476			ID:          catwalk.InferenceProviderVertexAI,
 477			APIKey:      "",
 478			APIEndpoint: "",
 479			Models: []catwalk.Model{{
 480				ID: "gemini-pro",
 481			}},
 482		},
 483	}
 484
 485	cfg := &Config{}
 486	cfg.setDefaults("/tmp", "")
 487	env := env.NewFromMap(map[string]string{
 488		"VERTEXAI_PROJECT":  "test-project",
 489		"VERTEXAI_LOCATION": "us-central1",
 490	})
 491	resolver := NewShellVariableResolver(env)
 492	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 493	require.NoError(t, err)
 494	require.Equal(t, cfg.Providers.Len(), 1)
 495
 496	vertexProvider, ok := cfg.Providers.Get("vertexai")
 497	require.True(t, ok, "VertexAI provider should be present")
 498	require.Len(t, vertexProvider.Models, 1)
 499	require.Equal(t, "gemini-pro", vertexProvider.Models[0].ID)
 500	require.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
 501	require.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
 502}
 503
 504func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
 505	knownProviders := []catwalk.Provider{
 506		{
 507			ID:          catwalk.InferenceProviderVertexAI,
 508			APIKey:      "",
 509			APIEndpoint: "",
 510			Models: []catwalk.Model{{
 511				ID: "gemini-pro",
 512			}},
 513		},
 514	}
 515
 516	cfg := &Config{}
 517	cfg.setDefaults("/tmp", "")
 518	env := env.NewFromMap(map[string]string{
 519		"GOOGLE_GENAI_USE_VERTEXAI": "false",
 520		"GOOGLE_CLOUD_PROJECT":      "test-project",
 521		"GOOGLE_CLOUD_LOCATION":     "us-central1",
 522	})
 523	resolver := NewShellVariableResolver(env)
 524	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 525	require.NoError(t, err)
 526	// Provider should not be configured without proper credentials
 527	require.Equal(t, cfg.Providers.Len(), 0)
 528}
 529
 530func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
 531	knownProviders := []catwalk.Provider{
 532		{
 533			ID:          catwalk.InferenceProviderVertexAI,
 534			APIKey:      "",
 535			APIEndpoint: "",
 536			Models: []catwalk.Model{{
 537				ID: "gemini-pro",
 538			}},
 539		},
 540	}
 541
 542	cfg := &Config{}
 543	cfg.setDefaults("/tmp", "")
 544	env := env.NewFromMap(map[string]string{
 545		"GOOGLE_GENAI_USE_VERTEXAI": "true",
 546		"GOOGLE_CLOUD_LOCATION":     "us-central1",
 547	})
 548	resolver := NewShellVariableResolver(env)
 549	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 550	require.NoError(t, err)
 551	// Provider should not be configured without project
 552	require.Equal(t, cfg.Providers.Len(), 0)
 553}
 554
 555func TestConfig_configureProvidersSetProviderID(t *testing.T) {
 556	knownProviders := []catwalk.Provider{
 557		{
 558			ID:          "openai",
 559			APIKey:      "$OPENAI_API_KEY",
 560			APIEndpoint: "https://api.openai.com/v1",
 561			Models: []catwalk.Model{{
 562				ID: "test-model",
 563			}},
 564		},
 565	}
 566
 567	cfg := &Config{}
 568	cfg.setDefaults("/tmp", "")
 569	env := env.NewFromMap(map[string]string{
 570		"OPENAI_API_KEY": "test-key",
 571	})
 572	resolver := NewShellVariableResolver(env)
 573	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 574	require.NoError(t, err)
 575	require.Equal(t, cfg.Providers.Len(), 1)
 576
 577	// Provider ID should be set
 578	pc, _ := cfg.Providers.Get("openai")
 579	require.Equal(t, "openai", pc.ID)
 580}
 581
 582func TestConfig_EnabledProviders(t *testing.T) {
 583	t.Run("all providers enabled", func(t *testing.T) {
 584		cfg := &Config{
 585			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 586				"openai": {
 587					ID:      "openai",
 588					APIKey:  "key1",
 589					Disable: false,
 590				},
 591				"anthropic": {
 592					ID:      "anthropic",
 593					APIKey:  "key2",
 594					Disable: false,
 595				},
 596			}),
 597		}
 598
 599		enabled := cfg.EnabledProviders()
 600		require.Len(t, enabled, 2)
 601	})
 602
 603	t.Run("some providers disabled", func(t *testing.T) {
 604		cfg := &Config{
 605			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 606				"openai": {
 607					ID:      "openai",
 608					APIKey:  "key1",
 609					Disable: false,
 610				},
 611				"anthropic": {
 612					ID:      "anthropic",
 613					APIKey:  "key2",
 614					Disable: true,
 615				},
 616			}),
 617		}
 618
 619		enabled := cfg.EnabledProviders()
 620		require.Len(t, enabled, 1)
 621		require.Equal(t, "openai", enabled[0].ID)
 622	})
 623
 624	t.Run("empty providers map", func(t *testing.T) {
 625		cfg := &Config{
 626			Providers: csync.NewMap[string, ProviderConfig](),
 627		}
 628
 629		enabled := cfg.EnabledProviders()
 630		require.Len(t, enabled, 0)
 631	})
 632}
 633
 634func TestConfig_IsConfigured(t *testing.T) {
 635	t.Run("returns true when at least one provider is enabled", func(t *testing.T) {
 636		cfg := &Config{
 637			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 638				"openai": {
 639					ID:      "openai",
 640					APIKey:  "key1",
 641					Disable: false,
 642				},
 643			}),
 644		}
 645
 646		require.True(t, cfg.IsConfigured())
 647	})
 648
 649	t.Run("returns false when no providers are configured", func(t *testing.T) {
 650		cfg := &Config{
 651			Providers: csync.NewMap[string, ProviderConfig](),
 652		}
 653
 654		require.False(t, cfg.IsConfigured())
 655	})
 656
 657	t.Run("returns false when all providers are disabled", func(t *testing.T) {
 658		cfg := &Config{
 659			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 660				"openai": {
 661					ID:      "openai",
 662					APIKey:  "key1",
 663					Disable: true,
 664				},
 665				"anthropic": {
 666					ID:      "anthropic",
 667					APIKey:  "key2",
 668					Disable: true,
 669				},
 670			}),
 671		}
 672
 673		require.False(t, cfg.IsConfigured())
 674	})
 675}
 676
 677func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
 678	cfg := &Config{
 679		Options: &Options{
 680			DisabledTools: []string{},
 681		},
 682	}
 683
 684	cfg.SetupAgents()
 685	coderAgent, ok := cfg.Agents[AgentCoder]
 686	require.True(t, ok)
 687	assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
 688
 689	taskAgent, ok := cfg.Agents[AgentTask]
 690	require.True(t, ok)
 691	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 692}
 693
 694func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 695	cfg := &Config{
 696		Options: &Options{
 697			DisabledTools: []string{
 698				"edit",
 699				"download",
 700				"grep",
 701			},
 702		},
 703	}
 704
 705	cfg.SetupAgents()
 706	coderAgent, ok := cfg.Agents[AgentCoder]
 707	require.True(t, ok)
 708
 709	assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 710
 711	taskAgent, ok := cfg.Agents[AgentTask]
 712	require.True(t, ok)
 713	assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 714}
 715
 716func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 717	cfg := &Config{
 718		Options: &Options{
 719			DisabledTools: []string{
 720				"glob",
 721				"grep",
 722				"ls",
 723				"sourcegraph",
 724				"view",
 725			},
 726		},
 727	}
 728
 729	cfg.SetupAgents()
 730	coderAgent, ok := cfg.Agents[AgentCoder]
 731	require.True(t, ok)
 732	assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 733
 734	taskAgent, ok := cfg.Agents[AgentTask]
 735	require.True(t, ok)
 736	assert.Len(t, taskAgent.AllowedTools, 0)
 737}
 738
 739func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
 740	knownProviders := []catwalk.Provider{
 741		{
 742			ID:          "openai",
 743			APIKey:      "$OPENAI_API_KEY",
 744			APIEndpoint: "https://api.openai.com/v1",
 745			Models: []catwalk.Model{{
 746				ID: "test-model",
 747			}},
 748		},
 749	}
 750
 751	cfg := &Config{
 752		Providers: csync.NewMapFrom(map[string]ProviderConfig{
 753			"openai": {
 754				Disable: true,
 755			},
 756		}),
 757	}
 758	cfg.setDefaults("/tmp", "")
 759
 760	env := env.NewFromMap(map[string]string{
 761		"OPENAI_API_KEY": "test-key",
 762	})
 763	resolver := NewShellVariableResolver(env)
 764	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 765	require.NoError(t, err)
 766
 767	require.Equal(t, cfg.Providers.Len(), 1)
 768	prov, exists := cfg.Providers.Get("openai")
 769	require.True(t, exists)
 770	require.True(t, prov.Disable)
 771}
 772
 773func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
 774	t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) {
 775		cfg := &Config{
 776			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 777				"custom": {
 778					BaseURL: "https://api.custom.com/v1",
 779					Models: []catwalk.Model{{
 780						ID: "test-model",
 781					}},
 782				},
 783				"openai": {
 784					APIKey: "$MISSING",
 785				},
 786			}),
 787		}
 788		cfg.setDefaults("/tmp", "")
 789
 790		env := env.NewFromMap(map[string]string{})
 791		resolver := NewShellVariableResolver(env)
 792		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 793		require.NoError(t, err)
 794
 795		require.Equal(t, cfg.Providers.Len(), 1)
 796		_, exists := cfg.Providers.Get("custom")
 797		require.True(t, exists)
 798	})
 799
 800	t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
 801		cfg := &Config{
 802			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 803				"custom": {
 804					APIKey: "test-key",
 805					Models: []catwalk.Model{{
 806						ID: "test-model",
 807					}},
 808				},
 809			}),
 810		}
 811		cfg.setDefaults("/tmp", "")
 812
 813		env := env.NewFromMap(map[string]string{})
 814		resolver := NewShellVariableResolver(env)
 815		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 816		require.NoError(t, err)
 817
 818		require.Equal(t, cfg.Providers.Len(), 0)
 819		_, exists := cfg.Providers.Get("custom")
 820		require.False(t, exists)
 821	})
 822
 823	t.Run("custom provider with no models is removed", func(t *testing.T) {
 824		cfg := &Config{
 825			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 826				"custom": {
 827					APIKey:  "test-key",
 828					BaseURL: "https://api.custom.com/v1",
 829					Models:  []catwalk.Model{},
 830				},
 831			}),
 832		}
 833		cfg.setDefaults("/tmp", "")
 834
 835		env := env.NewFromMap(map[string]string{})
 836		resolver := NewShellVariableResolver(env)
 837		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 838		require.NoError(t, err)
 839
 840		require.Equal(t, cfg.Providers.Len(), 0)
 841		_, exists := cfg.Providers.Get("custom")
 842		require.False(t, exists)
 843	})
 844
 845	t.Run("custom provider with unsupported type is removed", func(t *testing.T) {
 846		cfg := &Config{
 847			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 848				"custom": {
 849					APIKey:  "test-key",
 850					BaseURL: "https://api.custom.com/v1",
 851					Type:    "unsupported",
 852					Models: []catwalk.Model{{
 853						ID: "test-model",
 854					}},
 855				},
 856			}),
 857		}
 858		cfg.setDefaults("/tmp", "")
 859
 860		env := env.NewFromMap(map[string]string{})
 861		resolver := NewShellVariableResolver(env)
 862		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 863		require.NoError(t, err)
 864
 865		require.Equal(t, cfg.Providers.Len(), 0)
 866		_, exists := cfg.Providers.Get("custom")
 867		require.False(t, exists)
 868	})
 869
 870	t.Run("valid custom provider is kept and ID is set", func(t *testing.T) {
 871		cfg := &Config{
 872			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 873				"custom": {
 874					APIKey:  "test-key",
 875					BaseURL: "https://api.custom.com/v1",
 876					Type:    catwalk.TypeOpenAI,
 877					Models: []catwalk.Model{{
 878						ID: "test-model",
 879					}},
 880				},
 881			}),
 882		}
 883		cfg.setDefaults("/tmp", "")
 884
 885		env := env.NewFromMap(map[string]string{})
 886		resolver := NewShellVariableResolver(env)
 887		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 888		require.NoError(t, err)
 889
 890		require.Equal(t, cfg.Providers.Len(), 1)
 891		customProvider, exists := cfg.Providers.Get("custom")
 892		require.True(t, exists)
 893		require.Equal(t, "custom", customProvider.ID)
 894		require.Equal(t, "test-key", customProvider.APIKey)
 895		require.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
 896	})
 897
 898	t.Run("custom anthropic provider is supported", func(t *testing.T) {
 899		cfg := &Config{
 900			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 901				"custom-anthropic": {
 902					APIKey:  "test-key",
 903					BaseURL: "https://api.anthropic.com/v1",
 904					Type:    catwalk.TypeAnthropic,
 905					Models: []catwalk.Model{{
 906						ID: "claude-3-sonnet",
 907					}},
 908				},
 909			}),
 910		}
 911		cfg.setDefaults("/tmp", "")
 912
 913		env := env.NewFromMap(map[string]string{})
 914		resolver := NewShellVariableResolver(env)
 915		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 916		require.NoError(t, err)
 917
 918		require.Equal(t, cfg.Providers.Len(), 1)
 919		customProvider, exists := cfg.Providers.Get("custom-anthropic")
 920		require.True(t, exists)
 921		require.Equal(t, "custom-anthropic", customProvider.ID)
 922		require.Equal(t, "test-key", customProvider.APIKey)
 923		require.Equal(t, "https://api.anthropic.com/v1", customProvider.BaseURL)
 924		require.Equal(t, catwalk.TypeAnthropic, customProvider.Type)
 925	})
 926
 927	t.Run("disabled custom provider is removed", func(t *testing.T) {
 928		cfg := &Config{
 929			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 930				"custom": {
 931					APIKey:  "test-key",
 932					BaseURL: "https://api.custom.com/v1",
 933					Type:    catwalk.TypeOpenAI,
 934					Disable: true,
 935					Models: []catwalk.Model{{
 936						ID: "test-model",
 937					}},
 938				},
 939			}),
 940		}
 941		cfg.setDefaults("/tmp", "")
 942
 943		env := env.NewFromMap(map[string]string{})
 944		resolver := NewShellVariableResolver(env)
 945		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 946		require.NoError(t, err)
 947
 948		require.Equal(t, cfg.Providers.Len(), 0)
 949		_, exists := cfg.Providers.Get("custom")
 950		require.False(t, exists)
 951	})
 952}
 953
 954func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
 955	t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
 956		knownProviders := []catwalk.Provider{
 957			{
 958				ID:          catwalk.InferenceProviderVertexAI,
 959				APIKey:      "",
 960				APIEndpoint: "",
 961				Models: []catwalk.Model{{
 962					ID: "gemini-pro",
 963				}},
 964			},
 965		}
 966
 967		cfg := &Config{
 968			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 969				"vertexai": {
 970					BaseURL: "custom-url",
 971				},
 972			}),
 973		}
 974		cfg.setDefaults("/tmp", "")
 975
 976		env := env.NewFromMap(map[string]string{
 977			"GOOGLE_GENAI_USE_VERTEXAI": "false",
 978		})
 979		resolver := NewShellVariableResolver(env)
 980		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 981		require.NoError(t, err)
 982
 983		require.Equal(t, cfg.Providers.Len(), 0)
 984		_, exists := cfg.Providers.Get("vertexai")
 985		require.False(t, exists)
 986	})
 987
 988	t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
 989		knownProviders := []catwalk.Provider{
 990			{
 991				ID:          catwalk.InferenceProviderBedrock,
 992				APIKey:      "",
 993				APIEndpoint: "",
 994				Models: []catwalk.Model{{
 995					ID: "anthropic.claude-sonnet-4-20250514-v1:0",
 996				}},
 997			},
 998		}
 999
1000		cfg := &Config{
1001			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1002				"bedrock": {
1003					BaseURL: "custom-url",
1004				},
1005			}),
1006		}
1007		cfg.setDefaults("/tmp", "")
1008
1009		env := env.NewFromMap(map[string]string{})
1010		resolver := NewShellVariableResolver(env)
1011		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1012		require.NoError(t, err)
1013
1014		require.Equal(t, cfg.Providers.Len(), 0)
1015		_, exists := cfg.Providers.Get("bedrock")
1016		require.False(t, exists)
1017	})
1018
1019	t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
1020		knownProviders := []catwalk.Provider{
1021			{
1022				ID:          "openai",
1023				APIKey:      "$MISSING_API_KEY",
1024				APIEndpoint: "https://api.openai.com/v1",
1025				Models: []catwalk.Model{{
1026					ID: "test-model",
1027				}},
1028			},
1029		}
1030
1031		cfg := &Config{
1032			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1033				"openai": {
1034					BaseURL: "custom-url",
1035				},
1036			}),
1037		}
1038		cfg.setDefaults("/tmp", "")
1039
1040		env := env.NewFromMap(map[string]string{})
1041		resolver := NewShellVariableResolver(env)
1042		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1043		require.NoError(t, err)
1044
1045		require.Equal(t, cfg.Providers.Len(), 0)
1046		_, exists := cfg.Providers.Get("openai")
1047		require.False(t, exists)
1048	})
1049
1050	t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
1051		knownProviders := []catwalk.Provider{
1052			{
1053				ID:          "openai",
1054				APIKey:      "$OPENAI_API_KEY",
1055				APIEndpoint: "$MISSING_ENDPOINT",
1056				Models: []catwalk.Model{{
1057					ID: "test-model",
1058				}},
1059			},
1060		}
1061
1062		cfg := &Config{
1063			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1064				"openai": {
1065					APIKey: "test-key",
1066				},
1067			}),
1068		}
1069		cfg.setDefaults("/tmp", "")
1070
1071		env := env.NewFromMap(map[string]string{
1072			"OPENAI_API_KEY": "test-key",
1073		})
1074		resolver := NewShellVariableResolver(env)
1075		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1076		require.NoError(t, err)
1077
1078		require.Equal(t, cfg.Providers.Len(), 1)
1079		_, exists := cfg.Providers.Get("openai")
1080		require.True(t, exists)
1081	})
1082}
1083
1084func TestConfig_defaultModelSelection(t *testing.T) {
1085	t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
1086		knownProviders := []catwalk.Provider{
1087			{
1088				ID:                  "openai",
1089				APIKey:              "abc",
1090				DefaultLargeModelID: "large-model",
1091				DefaultSmallModelID: "small-model",
1092				Models: []catwalk.Model{
1093					{
1094						ID:               "large-model",
1095						DefaultMaxTokens: 1000,
1096					},
1097					{
1098						ID:               "small-model",
1099						DefaultMaxTokens: 500,
1100					},
1101				},
1102			},
1103		}
1104
1105		cfg := &Config{}
1106		cfg.setDefaults("/tmp", "")
1107		env := env.NewFromMap(map[string]string{})
1108		resolver := NewShellVariableResolver(env)
1109		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1110		require.NoError(t, err)
1111
1112		large, small, err := cfg.defaultModelSelection(knownProviders)
1113		require.NoError(t, err)
1114		require.Equal(t, "large-model", large.Model)
1115		require.Equal(t, "openai", large.Provider)
1116		require.Equal(t, int64(1000), large.MaxTokens)
1117		require.Equal(t, "small-model", small.Model)
1118		require.Equal(t, "openai", small.Provider)
1119		require.Equal(t, int64(500), small.MaxTokens)
1120	})
1121	t.Run("should error if no providers configured", func(t *testing.T) {
1122		knownProviders := []catwalk.Provider{
1123			{
1124				ID:                  "openai",
1125				APIKey:              "$MISSING_KEY",
1126				DefaultLargeModelID: "large-model",
1127				DefaultSmallModelID: "small-model",
1128				Models: []catwalk.Model{
1129					{
1130						ID:               "large-model",
1131						DefaultMaxTokens: 1000,
1132					},
1133					{
1134						ID:               "small-model",
1135						DefaultMaxTokens: 500,
1136					},
1137				},
1138			},
1139		}
1140
1141		cfg := &Config{}
1142		cfg.setDefaults("/tmp", "")
1143		env := env.NewFromMap(map[string]string{})
1144		resolver := NewShellVariableResolver(env)
1145		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1146		require.NoError(t, err)
1147
1148		_, _, err = cfg.defaultModelSelection(knownProviders)
1149		require.Error(t, err)
1150	})
1151	t.Run("should error if model is missing", func(t *testing.T) {
1152		knownProviders := []catwalk.Provider{
1153			{
1154				ID:                  "openai",
1155				APIKey:              "abc",
1156				DefaultLargeModelID: "large-model",
1157				DefaultSmallModelID: "small-model",
1158				Models: []catwalk.Model{
1159					{
1160						ID:               "not-large-model",
1161						DefaultMaxTokens: 1000,
1162					},
1163					{
1164						ID:               "small-model",
1165						DefaultMaxTokens: 500,
1166					},
1167				},
1168			},
1169		}
1170
1171		cfg := &Config{}
1172		cfg.setDefaults("/tmp", "")
1173		env := env.NewFromMap(map[string]string{})
1174		resolver := NewShellVariableResolver(env)
1175		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1176		require.NoError(t, err)
1177		_, _, err = cfg.defaultModelSelection(knownProviders)
1178		require.Error(t, err)
1179	})
1180
1181	t.Run("should configure the default models with a custom provider", func(t *testing.T) {
1182		knownProviders := []catwalk.Provider{
1183			{
1184				ID:                  "openai",
1185				APIKey:              "$MISSING", // will not be included in the config
1186				DefaultLargeModelID: "large-model",
1187				DefaultSmallModelID: "small-model",
1188				Models: []catwalk.Model{
1189					{
1190						ID:               "not-large-model",
1191						DefaultMaxTokens: 1000,
1192					},
1193					{
1194						ID:               "small-model",
1195						DefaultMaxTokens: 500,
1196					},
1197				},
1198			},
1199		}
1200
1201		cfg := &Config{
1202			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1203				"custom": {
1204					APIKey:  "test-key",
1205					BaseURL: "https://api.custom.com/v1",
1206					Models: []catwalk.Model{
1207						{
1208							ID:               "model",
1209							DefaultMaxTokens: 600,
1210						},
1211					},
1212				},
1213			}),
1214		}
1215		cfg.setDefaults("/tmp", "")
1216		env := env.NewFromMap(map[string]string{})
1217		resolver := NewShellVariableResolver(env)
1218		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1219		require.NoError(t, err)
1220		large, small, err := cfg.defaultModelSelection(knownProviders)
1221		require.NoError(t, err)
1222		require.Equal(t, "model", large.Model)
1223		require.Equal(t, "custom", large.Provider)
1224		require.Equal(t, int64(600), large.MaxTokens)
1225		require.Equal(t, "model", small.Model)
1226		require.Equal(t, "custom", small.Provider)
1227		require.Equal(t, int64(600), small.MaxTokens)
1228	})
1229
1230	t.Run("should fail if no model configured", func(t *testing.T) {
1231		knownProviders := []catwalk.Provider{
1232			{
1233				ID:                  "openai",
1234				APIKey:              "$MISSING", // will not be included in the config
1235				DefaultLargeModelID: "large-model",
1236				DefaultSmallModelID: "small-model",
1237				Models: []catwalk.Model{
1238					{
1239						ID:               "not-large-model",
1240						DefaultMaxTokens: 1000,
1241					},
1242					{
1243						ID:               "small-model",
1244						DefaultMaxTokens: 500,
1245					},
1246				},
1247			},
1248		}
1249
1250		cfg := &Config{
1251			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1252				"custom": {
1253					APIKey:  "test-key",
1254					BaseURL: "https://api.custom.com/v1",
1255					Models:  []catwalk.Model{},
1256				},
1257			}),
1258		}
1259		cfg.setDefaults("/tmp", "")
1260		env := env.NewFromMap(map[string]string{})
1261		resolver := NewShellVariableResolver(env)
1262		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1263		require.NoError(t, err)
1264		_, _, err = cfg.defaultModelSelection(knownProviders)
1265		require.Error(t, err)
1266	})
1267	t.Run("should use the default provider first", func(t *testing.T) {
1268		knownProviders := []catwalk.Provider{
1269			{
1270				ID:                  "openai",
1271				APIKey:              "set",
1272				DefaultLargeModelID: "large-model",
1273				DefaultSmallModelID: "small-model",
1274				Models: []catwalk.Model{
1275					{
1276						ID:               "large-model",
1277						DefaultMaxTokens: 1000,
1278					},
1279					{
1280						ID:               "small-model",
1281						DefaultMaxTokens: 500,
1282					},
1283				},
1284			},
1285		}
1286
1287		cfg := &Config{
1288			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1289				"custom": {
1290					APIKey:  "test-key",
1291					BaseURL: "https://api.custom.com/v1",
1292					Models: []catwalk.Model{
1293						{
1294							ID:               "large-model",
1295							DefaultMaxTokens: 1000,
1296						},
1297					},
1298				},
1299			}),
1300		}
1301		cfg.setDefaults("/tmp", "")
1302		env := env.NewFromMap(map[string]string{})
1303		resolver := NewShellVariableResolver(env)
1304		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1305		require.NoError(t, err)
1306		large, small, err := cfg.defaultModelSelection(knownProviders)
1307		require.NoError(t, err)
1308		require.Equal(t, "large-model", large.Model)
1309		require.Equal(t, "openai", large.Provider)
1310		require.Equal(t, int64(1000), large.MaxTokens)
1311		require.Equal(t, "small-model", small.Model)
1312		require.Equal(t, "openai", small.Provider)
1313		require.Equal(t, int64(500), small.MaxTokens)
1314	})
1315}
1316
1317func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1318	t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) {
1319		knownProviders := []catwalk.Provider{
1320			{
1321				ID:          "openai",
1322				APIKey:      "$OPENAI_API_KEY",
1323				APIEndpoint: "https://api.openai.com/v1",
1324				Models: []catwalk.Model{{
1325					ID: "gpt-4",
1326				}},
1327			},
1328		}
1329
1330		// User references openai but doesn't fully specify it (no base_url, no
1331		// models). This should be rejected because disable_default_providers
1332		// treats all providers as custom.
1333		cfg := &Config{
1334			Options: &Options{
1335				DisableDefaultProviders: true,
1336			},
1337			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1338				"openai": {
1339					APIKey: "$OPENAI_API_KEY",
1340				},
1341			}),
1342		}
1343		cfg.setDefaults("/tmp", "")
1344
1345		env := env.NewFromMap(map[string]string{
1346			"OPENAI_API_KEY": "test-key",
1347		})
1348		resolver := NewShellVariableResolver(env)
1349		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1350		require.ErrorContains(t, err, "no custom providers")
1351
1352		// openai should NOT be present because it lacks base_url and models.
1353		require.Equal(t, 0, cfg.Providers.Len())
1354		_, exists := cfg.Providers.Get("openai")
1355		require.False(t, exists, "openai should not be present without full specification")
1356	})
1357
1358	t.Run("when enabled, fully specified providers work", func(t *testing.T) {
1359		knownProviders := []catwalk.Provider{
1360			{
1361				ID:          "openai",
1362				APIKey:      "$OPENAI_API_KEY",
1363				APIEndpoint: "https://api.openai.com/v1",
1364				Models: []catwalk.Model{{
1365					ID: "gpt-4",
1366				}},
1367			},
1368		}
1369
1370		// User fully specifies their provider.
1371		cfg := &Config{
1372			Options: &Options{
1373				DisableDefaultProviders: true,
1374			},
1375			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1376				"my-llm": {
1377					APIKey:  "$MY_API_KEY",
1378					BaseURL: "https://my-llm.example.com/v1",
1379					Models: []catwalk.Model{{
1380						ID: "my-model",
1381					}},
1382				},
1383			}),
1384		}
1385		cfg.setDefaults("/tmp", "")
1386
1387		env := env.NewFromMap(map[string]string{
1388			"MY_API_KEY":     "test-key",
1389			"OPENAI_API_KEY": "test-key",
1390		})
1391		resolver := NewShellVariableResolver(env)
1392		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1393		require.NoError(t, err)
1394
1395		// Only fully specified provider should be present.
1396		require.Equal(t, 1, cfg.Providers.Len())
1397		provider, exists := cfg.Providers.Get("my-llm")
1398		require.True(t, exists, "my-llm should be present")
1399		require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL)
1400		require.Len(t, provider.Models, 1)
1401
1402		// Default openai should NOT be present.
1403		_, exists = cfg.Providers.Get("openai")
1404		require.False(t, exists, "openai should not be present")
1405	})
1406
1407	t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) {
1408		knownProviders := []catwalk.Provider{
1409			{
1410				ID:          "openai",
1411				APIKey:      "$OPENAI_API_KEY",
1412				APIEndpoint: "https://api.openai.com/v1",
1413				Models: []catwalk.Model{{
1414					ID: "gpt-4",
1415				}},
1416			},
1417			{
1418				ID:          "anthropic",
1419				APIKey:      "$ANTHROPIC_API_KEY",
1420				APIEndpoint: "https://api.anthropic.com/v1",
1421				Models: []catwalk.Model{{
1422					ID: "claude-3",
1423				}},
1424			},
1425		}
1426
1427		// User only configures openai, both API keys are available, but option
1428		// is disabled.
1429		cfg := &Config{
1430			Options: &Options{
1431				DisableDefaultProviders: false,
1432			},
1433			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1434				"openai": {
1435					APIKey: "$OPENAI_API_KEY",
1436				},
1437			}),
1438		}
1439		cfg.setDefaults("/tmp", "")
1440
1441		env := env.NewFromMap(map[string]string{
1442			"OPENAI_API_KEY":    "test-key",
1443			"ANTHROPIC_API_KEY": "test-key",
1444		})
1445		resolver := NewShellVariableResolver(env)
1446		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1447		require.NoError(t, err)
1448
1449		// Both providers should be present.
1450		require.Equal(t, 2, cfg.Providers.Len())
1451		_, exists := cfg.Providers.Get("openai")
1452		require.True(t, exists, "openai should be present")
1453		_, exists = cfg.Providers.Get("anthropic")
1454		require.True(t, exists, "anthropic should be present")
1455	})
1456
1457	t.Run("when enabled, provider missing models is rejected", func(t *testing.T) {
1458		cfg := &Config{
1459			Options: &Options{
1460				DisableDefaultProviders: true,
1461			},
1462			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1463				"my-llm": {
1464					APIKey:  "test-key",
1465					BaseURL: "https://my-llm.example.com/v1",
1466					Models:  []catwalk.Model{}, // No models.
1467				},
1468			}),
1469		}
1470		cfg.setDefaults("/tmp", "")
1471
1472		env := env.NewFromMap(map[string]string{})
1473		resolver := NewShellVariableResolver(env)
1474		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1475		require.ErrorContains(t, err, "no custom providers")
1476
1477		// Provider should be rejected for missing models.
1478		require.Equal(t, 0, cfg.Providers.Len())
1479	})
1480
1481	t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) {
1482		cfg := &Config{
1483			Options: &Options{
1484				DisableDefaultProviders: true,
1485			},
1486			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1487				"my-llm": {
1488					APIKey: "test-key",
1489					Models: []catwalk.Model{{ID: "model"}},
1490					// No BaseURL.
1491				},
1492			}),
1493		}
1494		cfg.setDefaults("/tmp", "")
1495
1496		env := env.NewFromMap(map[string]string{})
1497		resolver := NewShellVariableResolver(env)
1498		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1499		require.ErrorContains(t, err, "no custom providers")
1500
1501		// Provider should be rejected for missing base_url.
1502		require.Equal(t, 0, cfg.Providers.Len())
1503	})
1504}
1505
1506func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) {
1507	t.Run("sets option from environment variable", func(t *testing.T) {
1508		t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true")
1509
1510		cfg := &Config{}
1511		cfg.setDefaults("/tmp", "")
1512
1513		require.True(t, cfg.Options.DisableDefaultProviders)
1514	})
1515
1516	t.Run("does not override when env var is not set", func(t *testing.T) {
1517		cfg := &Config{
1518			Options: &Options{
1519				DisableDefaultProviders: true,
1520			},
1521		}
1522		cfg.setDefaults("/tmp", "")
1523
1524		require.True(t, cfg.Options.DisableDefaultProviders)
1525	})
1526}
1527
1528func TestConfig_configureSelectedModels(t *testing.T) {
1529	t.Run("reload mode should not persist fallback defaults", func(t *testing.T) {
1530		dir := t.TempDir()
1531		globalPath := filepath.Join(dir, "crush.json")
1532		require.NoError(t, os.WriteFile(globalPath, []byte(`{"models":{"large":{"provider":"ghost","model":"missing"}}}`), 0o600))
1533
1534		knownProviders := []catwalk.Provider{
1535			{
1536				ID:                  "openai",
1537				APIKey:              "abc",
1538				DefaultLargeModelID: "large-model",
1539				DefaultSmallModelID: "small-model",
1540				Models: []catwalk.Model{
1541					{ID: "large-model", DefaultMaxTokens: 1000},
1542					{ID: "small-model", DefaultMaxTokens: 500},
1543				},
1544			},
1545		}
1546
1547		cfg := &Config{
1548			Models: map[SelectedModelType]SelectedModel{
1549				SelectedModelTypeLarge: {Provider: "ghost", Model: "missing"},
1550			},
1551		}
1552		cfg.setDefaults(dir, "")
1553		store := &ConfigStore{config: cfg, globalDataPath: globalPath}
1554		env := env.NewFromMap(map[string]string{})
1555		resolver := NewShellVariableResolver(env)
1556		err := cfg.configureProviders(store, env, resolver, knownProviders)
1557		require.NoError(t, err)
1558
1559		err = configureSelectedModels(store, knownProviders, false)
1560		require.NoError(t, err)
1561
1562		// In-memory falls back to default.
1563		require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
1564		require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
1565
1566		// Disk remains unchanged in reload mode.
1567		data, readErr := os.ReadFile(globalPath)
1568		require.NoError(t, readErr)
1569		require.Contains(t, string(data), `"provider":"ghost"`)
1570		require.Contains(t, string(data), `"model":"missing"`)
1571	})
1572	t.Run("should override defaults", func(t *testing.T) {
1573		knownProviders := []catwalk.Provider{
1574			{
1575				ID:                  "openai",
1576				APIKey:              "abc",
1577				DefaultLargeModelID: "large-model",
1578				DefaultSmallModelID: "small-model",
1579				Models: []catwalk.Model{
1580					{
1581						ID:               "larger-model",
1582						DefaultMaxTokens: 2000,
1583					},
1584					{
1585						ID:               "large-model",
1586						DefaultMaxTokens: 1000,
1587					},
1588					{
1589						ID:               "small-model",
1590						DefaultMaxTokens: 500,
1591					},
1592				},
1593			},
1594		}
1595
1596		cfg := &Config{
1597			Models: map[SelectedModelType]SelectedModel{
1598				"large": {
1599					Model: "larger-model",
1600				},
1601			},
1602		}
1603		cfg.setDefaults("/tmp", "")
1604		env := env.NewFromMap(map[string]string{})
1605		resolver := NewShellVariableResolver(env)
1606		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1607		require.NoError(t, err)
1608
1609		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1610		require.NoError(t, err)
1611		large := cfg.Models[SelectedModelTypeLarge]
1612		small := cfg.Models[SelectedModelTypeSmall]
1613		require.Equal(t, "larger-model", large.Model)
1614		require.Equal(t, "openai", large.Provider)
1615		require.Equal(t, int64(2000), large.MaxTokens)
1616		require.Equal(t, "small-model", small.Model)
1617		require.Equal(t, "openai", small.Provider)
1618		require.Equal(t, int64(500), small.MaxTokens)
1619	})
1620	t.Run("should be possible to use multiple providers", func(t *testing.T) {
1621		knownProviders := []catwalk.Provider{
1622			{
1623				ID:                  "openai",
1624				APIKey:              "abc",
1625				DefaultLargeModelID: "large-model",
1626				DefaultSmallModelID: "small-model",
1627				Models: []catwalk.Model{
1628					{
1629						ID:               "large-model",
1630						DefaultMaxTokens: 1000,
1631					},
1632					{
1633						ID:               "small-model",
1634						DefaultMaxTokens: 500,
1635					},
1636				},
1637			},
1638			{
1639				ID:                  "anthropic",
1640				APIKey:              "abc",
1641				DefaultLargeModelID: "a-large-model",
1642				DefaultSmallModelID: "a-small-model",
1643				Models: []catwalk.Model{
1644					{
1645						ID:               "a-large-model",
1646						DefaultMaxTokens: 1000,
1647					},
1648					{
1649						ID:               "a-small-model",
1650						DefaultMaxTokens: 200,
1651					},
1652				},
1653			},
1654		}
1655
1656		cfg := &Config{
1657			Models: map[SelectedModelType]SelectedModel{
1658				"small": {
1659					Model:     "a-small-model",
1660					Provider:  "anthropic",
1661					MaxTokens: 300,
1662				},
1663			},
1664		}
1665		cfg.setDefaults("/tmp", "")
1666		env := env.NewFromMap(map[string]string{})
1667		resolver := NewShellVariableResolver(env)
1668		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1669		require.NoError(t, err)
1670
1671		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1672		require.NoError(t, err)
1673		large := cfg.Models[SelectedModelTypeLarge]
1674		small := cfg.Models[SelectedModelTypeSmall]
1675		require.Equal(t, "large-model", large.Model)
1676		require.Equal(t, "openai", large.Provider)
1677		require.Equal(t, int64(1000), large.MaxTokens)
1678		require.Equal(t, "a-small-model", small.Model)
1679		require.Equal(t, "anthropic", small.Provider)
1680		require.Equal(t, int64(300), small.MaxTokens)
1681	})
1682
1683	t.Run("should override the max tokens only", func(t *testing.T) {
1684		knownProviders := []catwalk.Provider{
1685			{
1686				ID:                  "openai",
1687				APIKey:              "abc",
1688				DefaultLargeModelID: "large-model",
1689				DefaultSmallModelID: "small-model",
1690				Models: []catwalk.Model{
1691					{
1692						ID:               "large-model",
1693						DefaultMaxTokens: 1000,
1694					},
1695					{
1696						ID:               "small-model",
1697						DefaultMaxTokens: 500,
1698					},
1699				},
1700			},
1701		}
1702
1703		cfg := &Config{
1704			Models: map[SelectedModelType]SelectedModel{
1705				"large": {
1706					MaxTokens: 100,
1707				},
1708			},
1709		}
1710		cfg.setDefaults("/tmp", "")
1711		env := env.NewFromMap(map[string]string{})
1712		resolver := NewShellVariableResolver(env)
1713		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1714		require.NoError(t, err)
1715
1716		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1717		require.NoError(t, err)
1718		large := cfg.Models[SelectedModelTypeLarge]
1719		require.Equal(t, "large-model", large.Model)
1720		require.Equal(t, "openai", large.Provider)
1721		require.Equal(t, int64(100), large.MaxTokens)
1722	})
1723}
1724
1725func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {
1726	// Test that HYPER_API_KEY environment variable works without config
1727	knownProviders := []catwalk.Provider{
1728		{
1729			ID:                  "hyper",
1730			APIKey:              "", // No API key in provider definition
1731			DefaultLargeModelID: "large-model",
1732			DefaultSmallModelID: "small-model",
1733			Models: []catwalk.Model{
1734				{
1735					ID:               "large-model",
1736					DefaultMaxTokens: 1000,
1737				},
1738				{
1739					ID:               "small-model",
1740					DefaultMaxTokens: 500,
1741				},
1742			},
1743		},
1744	}
1745
1746	cfg := &Config{}
1747	cfg.setDefaults("/tmp", "")
1748	env := env.NewFromMap(map[string]string{
1749		"HYPER_API_KEY": "env-api-key",
1750	})
1751	resolver := NewShellVariableResolver(env)
1752	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1753	require.NoError(t, err)
1754	require.Equal(t, 1, cfg.Providers.Len())
1755
1756	// Verify Hyper provider is configured with the env var API key
1757	pc, ok := cfg.Providers.Get("hyper")
1758	require.True(t, ok, "Hyper provider should be configured")
1759	require.Equal(t, "env-api-key", pc.APIKey)
1760	require.Equal(t, "env-api-key", pc.APIKeyTemplate)
1761}
1762
1763func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T) {
1764	// Test that config API key takes precedence when HYPER_API_KEY is also set
1765	knownProviders := []catwalk.Provider{
1766		{
1767			ID:                  "hyper",
1768			APIKey:              "provider-api-key",
1769			DefaultLargeModelID: "large-model",
1770			DefaultSmallModelID: "small-model",
1771			Models: []catwalk.Model{
1772				{
1773					ID:               "large-model",
1774					DefaultMaxTokens: 1000,
1775				},
1776				{
1777					ID:               "small-model",
1778					DefaultMaxTokens: 500,
1779				},
1780			},
1781		},
1782	}
1783
1784	// User has Hyper configured with an API key
1785	cfg := &Config{
1786		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1787			"hyper": {
1788				APIKey: "config-api-key",
1789			},
1790		}),
1791	}
1792	cfg.setDefaults("/tmp", "")
1793
1794	// But they also have HYPER_API_KEY set - env var should take precedence
1795	env := env.NewFromMap(map[string]string{
1796		"HYPER_API_KEY": "env-api-key",
1797	})
1798	resolver := NewShellVariableResolver(env)
1799	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1800	require.NoError(t, err)
1801	require.Equal(t, 1, cfg.Providers.Len())
1802
1803	// Verify env var takes precedence (as per requirements)
1804	pc, ok := cfg.Providers.Get("hyper")
1805	require.True(t, ok, "Hyper provider should be configured")
1806	require.Equal(t, "env-api-key", pc.APIKey)
1807}
1808
1809// TestConfig_configureProviders_ProviderHeaderResolveError verifies
1810// that a failing $(cmd) in a provider header fails the provider load
1811// with a clear message that names the offending header. Provider
1812// headers share the MCP error contract.
1813func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
1814	knownProviders := []catwalk.Provider{
1815		{
1816			ID:          "openai",
1817			APIKey:      "$OPENAI_API_KEY",
1818			APIEndpoint: "https://api.openai.com/v1",
1819			Models:      []catwalk.Model{{ID: "test-model"}},
1820		},
1821	}
1822
1823	cfg := &Config{
1824		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1825			"openai": {
1826				ExtraHeaders: map[string]string{
1827					// Failing $(...) — inner command exits 1. Must
1828					// propagate as an error, not a silent truncation.
1829					"X-Broken": "$(false)",
1830				},
1831			},
1832		}),
1833	}
1834	cfg.setDefaults("/tmp", "")
1835
1836	testEnv := env.NewFromMap(map[string]string{
1837		"OPENAI_API_KEY": "test-key",
1838		"PATH":           os.Getenv("PATH"),
1839	})
1840	resolver := NewShellVariableResolver(testEnv)
1841
1842	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1843	require.Error(t, err, "failing $(cmd) in a header must fail the provider load")
1844	require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
1845}
1846
1847// TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads
1848// verifies that a Catwalk-style default header like
1849// "OpenAI-Organization": "$OPENAI_ORG_ID" loads cleanly under lenient
1850// nounset (unset → "" → header dropped), and does not fail the load
1851// or leave the literal template on the wire.
1852func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
1853	knownProviders := []catwalk.Provider{
1854		{
1855			ID:          "openai",
1856			APIKey:      "$OPENAI_API_KEY",
1857			APIEndpoint: "https://api.openai.com/v1",
1858			Models:      []catwalk.Model{{ID: "test-model"}},
1859			DefaultHeaders: map[string]string{
1860				"OpenAI-Organization": "$OPENAI_ORG_ID",
1861			},
1862		},
1863	}
1864
1865	cfg := &Config{}
1866	cfg.setDefaults("/tmp", "")
1867
1868	testEnv := env.NewFromMap(map[string]string{
1869		"OPENAI_API_KEY": "test-key",
1870		"PATH":           os.Getenv("PATH"),
1871	})
1872	resolver := NewShellVariableResolver(testEnv)
1873
1874	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1875	require.NoError(t, err, "optional env-gated header must not fail the load")
1876
1877	pc, ok := cfg.Providers.Get("openai")
1878	require.True(t, ok, "openai provider must still be configured")
1879	_, present := pc.ExtraHeaders["OpenAI-Organization"]
1880	require.False(t, present, "header whose value resolves to empty must be absent")
1881}
1882
1883// TestConfig_configureProviders_LiteralEmptyHeaderDropped pins design
1884// decision #18 for the literal case: a user-authored
1885// "X-Custom": "" in extra_headers is absent from the resolved map.
1886// Applies to both known- and custom-provider paths; this test
1887// exercises the custom-provider loop.
1888func TestConfig_configureProviders_LiteralEmptyHeaderDropped(t *testing.T) {
1889	cfg := &Config{
1890		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1891			"my-llm": {
1892				APIKey:  "test-key",
1893				BaseURL: "https://my-llm.example.com/v1",
1894				Type:    catwalk.TypeOpenAI,
1895				Models:  []catwalk.Model{{ID: "m"}},
1896				ExtraHeaders: map[string]string{
1897					"X-Custom": "",
1898					"X-Kept":   "present",
1899				},
1900			},
1901		}),
1902	}
1903	cfg.setDefaults("/tmp", "")
1904
1905	testEnv := env.NewFromMap(map[string]string{
1906		"PATH": os.Getenv("PATH"),
1907	})
1908	resolver := NewShellVariableResolver(testEnv)
1909
1910	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, []catwalk.Provider{})
1911	require.NoError(t, err)
1912
1913	pc, ok := cfg.Providers.Get("my-llm")
1914	require.True(t, ok)
1915	_, present := pc.ExtraHeaders["X-Custom"]
1916	require.False(t, present, "literal empty-string header must be dropped")
1917	require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1918}
1919
1920// TestConfig_configureProviders_EchoEmptyHeaderDropped pins design
1921// decision #18 for the non-failing empty case: $(echo) exits 0 with
1922// empty output, resolves cleanly to "", and must be dropped the same
1923// way an unset bare $VAR is. Exercises the known-provider loop.
1924func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
1925	knownProviders := []catwalk.Provider{
1926		{
1927			ID:          "openai",
1928			APIKey:      "$OPENAI_API_KEY",
1929			APIEndpoint: "https://api.openai.com/v1",
1930			Models:      []catwalk.Model{{ID: "test-model"}},
1931			DefaultHeaders: map[string]string{
1932				"X-Empty": "$(echo)",
1933				"X-Kept":  "present",
1934			},
1935		},
1936	}
1937
1938	cfg := &Config{}
1939	cfg.setDefaults("/tmp", "")
1940
1941	testEnv := env.NewFromMap(map[string]string{
1942		"OPENAI_API_KEY": "test-key",
1943		"PATH":           os.Getenv("PATH"),
1944	})
1945	resolver := NewShellVariableResolver(testEnv)
1946
1947	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1948	require.NoError(t, err)
1949
1950	pc, ok := cfg.Providers.Get("openai")
1951	require.True(t, ok)
1952	_, present := pc.ExtraHeaders["X-Empty"]
1953	require.False(t, present, "$(echo) → empty → header must be dropped")
1954	require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1955}
1956
1957// TestConfig_configureProviders_UnsetAPIKeySkipsProvider verifies that
1958// under the lenient-nounset shell resolver, $UNSET_API_KEY expands to
1959// ("", nil) rather than ("", err), and the existing
1960// `v == "" || err != nil` skip path at load.go:331 still drops the
1961// provider. The slog.Warn line is emitted on the same
1962// path but is not asserted here — internal/config/load_test.go's
1963// TestMain replaces the default slog handler with an io.Discard
1964// writer, so capturing that log line would require mid-test handler
1965// swapping and a sync.Mutex dance that adds more flake surface than
1966// signal. The observable outcome (provider absent from the map) is
1967// what downstream code — model picker, agent wiring — actually reads,
1968// so that's what we pin.
1969func TestConfig_configureProviders_UnsetAPIKeySkipsProvider(t *testing.T) {
1970	knownProviders := []catwalk.Provider{
1971		{
1972			ID:          "openai",
1973			APIKey:      "$SOMETHING_UNSET",
1974			APIEndpoint: "https://api.openai.com/v1",
1975			Models:      []catwalk.Model{{ID: "test-model"}},
1976		},
1977	}
1978
1979	// Existing user config for this known provider so the load.go:332
1980	// `if configExists` branch fires and actually calls Providers.Del.
1981	// Without it the provider was never in the map to begin with and
1982	// the test would pass trivially.
1983	cfg := &Config{
1984		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1985			"openai": {BaseURL: "custom-url"},
1986		}),
1987	}
1988	cfg.setDefaults("/tmp", "")
1989
1990	testEnv := env.NewFromMap(map[string]string{
1991		"PATH": os.Getenv("PATH"),
1992	})
1993	resolver := NewShellVariableResolver(testEnv)
1994
1995	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1996	require.NoError(t, err, "skip path must not surface as a load error")
1997
1998	require.Equal(t, 0, cfg.Providers.Len(), "provider with unset API key must be skipped")
1999	_, exists := cfg.Providers.Get("openai")
2000	require.False(t, exists)
2001}
2002
2003// TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider pins
2004// that the two failure modes for APIKey — ("", nil) from an unset var
2005// under lenient nounset and ("", err) from a failing $(cmd) — are
2006// equivalent for the skip outcome at load.go:331. The `v == "" ||
2007// err != nil` check fires on either branch; this test locks in that
2008// equivalence so a future refactor that splits the check into two
2009// paths doesn't accidentally start propagating $(false) as a load
2010// error while keeping unset-var as a silent skip (or vice versa).
2011func TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider(t *testing.T) {
2012	knownProviders := []catwalk.Provider{
2013		{
2014			ID:          "openai",
2015			APIKey:      "$(false)",
2016			APIEndpoint: "https://api.openai.com/v1",
2017			Models:      []catwalk.Model{{ID: "test-model"}},
2018		},
2019	}
2020
2021	cfg := &Config{
2022		Providers: csync.NewMapFrom(map[string]ProviderConfig{
2023			"openai": {BaseURL: "custom-url"},
2024		}),
2025	}
2026	cfg.setDefaults("/tmp", "")
2027
2028	testEnv := env.NewFromMap(map[string]string{
2029		"PATH": os.Getenv("PATH"),
2030	})
2031	resolver := NewShellVariableResolver(testEnv)
2032
2033	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2034	require.NoError(t, err, "failing $(cmd) in API key must skip provider, not fail load")
2035
2036	require.Equal(t, 0, cfg.Providers.Len(), "provider with failing $(cmd) API key must be skipped")
2037	_, exists := cfg.Providers.Get("openai")
2038	require.False(t, exists)
2039}
2040
2041// TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider pins
2042// the same contract on the Azure path at load.go:287 — APIEndpoint is
2043// the field that gates Azure and goes through the same
2044// `v == "" || err != nil` skip check. Covered here so both branches
2045// of the shared skip pattern (APIKey default path and APIEndpoint
2046// Azure path) are tested; a future refactor that unifies them can
2047// rely on these two tests to catch drift.
2048func TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider(t *testing.T) {
2049	knownProviders := []catwalk.Provider{
2050		{
2051			ID:          catwalk.InferenceProviderAzure,
2052			APIKey:      "test-key",
2053			APIEndpoint: "$UNSET_AZURE_ENDPOINT",
2054			Models:      []catwalk.Model{{ID: "test-model"}},
2055		},
2056	}
2057
2058	cfg := &Config{
2059		Providers: csync.NewMapFrom(map[string]ProviderConfig{
2060			"azure": {BaseURL: ""},
2061		}),
2062	}
2063	cfg.setDefaults("/tmp", "")
2064
2065	testEnv := env.NewFromMap(map[string]string{
2066		"PATH": os.Getenv("PATH"),
2067	})
2068	resolver := NewShellVariableResolver(testEnv)
2069
2070	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2071	require.NoError(t, err)
2072
2073	require.Equal(t, 0, cfg.Providers.Len(), "azure provider with unset endpoint must be skipped")
2074	_, exists := cfg.Providers.Get("azure")
2075	require.False(t, exists)
2076}