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_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
 474	knownProviders := []catwalk.Provider{
 475		{
 476			ID:          catwalk.InferenceProviderBedrock,
 477			APIKey:      "",
 478			APIEndpoint: "",
 479			Models: []catwalk.Model{{
 480				ID: "some-random-model",
 481			}},
 482		},
 483	}
 484
 485	cfg := &Config{}
 486	cfg.setDefaults("/tmp", "")
 487	env := env.NewFromMap(map[string]string{
 488		"AWS_ACCESS_KEY_ID":     "test-key-id",
 489		"AWS_SECRET_ACCESS_KEY": "test-secret-key",
 490	})
 491	resolver := NewShellVariableResolver(env)
 492	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 493	require.Error(t, err)
 494}
 495
 496func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
 497	knownProviders := []catwalk.Provider{
 498		{
 499			ID:          catwalk.InferenceProviderVertexAI,
 500			APIKey:      "",
 501			APIEndpoint: "",
 502			Models: []catwalk.Model{{
 503				ID: "gemini-pro",
 504			}},
 505		},
 506	}
 507
 508	cfg := &Config{}
 509	cfg.setDefaults("/tmp", "")
 510	env := env.NewFromMap(map[string]string{
 511		"VERTEXAI_PROJECT":  "test-project",
 512		"VERTEXAI_LOCATION": "us-central1",
 513	})
 514	resolver := NewShellVariableResolver(env)
 515	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 516	require.NoError(t, err)
 517	require.Equal(t, cfg.Providers.Len(), 1)
 518
 519	vertexProvider, ok := cfg.Providers.Get("vertexai")
 520	require.True(t, ok, "VertexAI provider should be present")
 521	require.Len(t, vertexProvider.Models, 1)
 522	require.Equal(t, "gemini-pro", vertexProvider.Models[0].ID)
 523	require.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
 524	require.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
 525}
 526
 527func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
 528	knownProviders := []catwalk.Provider{
 529		{
 530			ID:          catwalk.InferenceProviderVertexAI,
 531			APIKey:      "",
 532			APIEndpoint: "",
 533			Models: []catwalk.Model{{
 534				ID: "gemini-pro",
 535			}},
 536		},
 537	}
 538
 539	cfg := &Config{}
 540	cfg.setDefaults("/tmp", "")
 541	env := env.NewFromMap(map[string]string{
 542		"GOOGLE_GENAI_USE_VERTEXAI": "false",
 543		"GOOGLE_CLOUD_PROJECT":      "test-project",
 544		"GOOGLE_CLOUD_LOCATION":     "us-central1",
 545	})
 546	resolver := NewShellVariableResolver(env)
 547	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 548	require.NoError(t, err)
 549	// Provider should not be configured without proper credentials
 550	require.Equal(t, cfg.Providers.Len(), 0)
 551}
 552
 553func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
 554	knownProviders := []catwalk.Provider{
 555		{
 556			ID:          catwalk.InferenceProviderVertexAI,
 557			APIKey:      "",
 558			APIEndpoint: "",
 559			Models: []catwalk.Model{{
 560				ID: "gemini-pro",
 561			}},
 562		},
 563	}
 564
 565	cfg := &Config{}
 566	cfg.setDefaults("/tmp", "")
 567	env := env.NewFromMap(map[string]string{
 568		"GOOGLE_GENAI_USE_VERTEXAI": "true",
 569		"GOOGLE_CLOUD_LOCATION":     "us-central1",
 570	})
 571	resolver := NewShellVariableResolver(env)
 572	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 573	require.NoError(t, err)
 574	// Provider should not be configured without project
 575	require.Equal(t, cfg.Providers.Len(), 0)
 576}
 577
 578func TestConfig_configureProvidersSetProviderID(t *testing.T) {
 579	knownProviders := []catwalk.Provider{
 580		{
 581			ID:          "openai",
 582			APIKey:      "$OPENAI_API_KEY",
 583			APIEndpoint: "https://api.openai.com/v1",
 584			Models: []catwalk.Model{{
 585				ID: "test-model",
 586			}},
 587		},
 588	}
 589
 590	cfg := &Config{}
 591	cfg.setDefaults("/tmp", "")
 592	env := env.NewFromMap(map[string]string{
 593		"OPENAI_API_KEY": "test-key",
 594	})
 595	resolver := NewShellVariableResolver(env)
 596	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 597	require.NoError(t, err)
 598	require.Equal(t, cfg.Providers.Len(), 1)
 599
 600	// Provider ID should be set
 601	pc, _ := cfg.Providers.Get("openai")
 602	require.Equal(t, "openai", pc.ID)
 603}
 604
 605func TestConfig_EnabledProviders(t *testing.T) {
 606	t.Run("all providers enabled", func(t *testing.T) {
 607		cfg := &Config{
 608			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 609				"openai": {
 610					ID:      "openai",
 611					APIKey:  "key1",
 612					Disable: false,
 613				},
 614				"anthropic": {
 615					ID:      "anthropic",
 616					APIKey:  "key2",
 617					Disable: false,
 618				},
 619			}),
 620		}
 621
 622		enabled := cfg.EnabledProviders()
 623		require.Len(t, enabled, 2)
 624	})
 625
 626	t.Run("some providers disabled", func(t *testing.T) {
 627		cfg := &Config{
 628			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 629				"openai": {
 630					ID:      "openai",
 631					APIKey:  "key1",
 632					Disable: false,
 633				},
 634				"anthropic": {
 635					ID:      "anthropic",
 636					APIKey:  "key2",
 637					Disable: true,
 638				},
 639			}),
 640		}
 641
 642		enabled := cfg.EnabledProviders()
 643		require.Len(t, enabled, 1)
 644		require.Equal(t, "openai", enabled[0].ID)
 645	})
 646
 647	t.Run("empty providers map", func(t *testing.T) {
 648		cfg := &Config{
 649			Providers: csync.NewMap[string, ProviderConfig](),
 650		}
 651
 652		enabled := cfg.EnabledProviders()
 653		require.Len(t, enabled, 0)
 654	})
 655}
 656
 657func TestConfig_IsConfigured(t *testing.T) {
 658	t.Run("returns true when at least one provider is enabled", func(t *testing.T) {
 659		cfg := &Config{
 660			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 661				"openai": {
 662					ID:      "openai",
 663					APIKey:  "key1",
 664					Disable: false,
 665				},
 666			}),
 667		}
 668
 669		require.True(t, cfg.IsConfigured())
 670	})
 671
 672	t.Run("returns false when no providers are configured", func(t *testing.T) {
 673		cfg := &Config{
 674			Providers: csync.NewMap[string, ProviderConfig](),
 675		}
 676
 677		require.False(t, cfg.IsConfigured())
 678	})
 679
 680	t.Run("returns false when all providers are disabled", func(t *testing.T) {
 681		cfg := &Config{
 682			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 683				"openai": {
 684					ID:      "openai",
 685					APIKey:  "key1",
 686					Disable: true,
 687				},
 688				"anthropic": {
 689					ID:      "anthropic",
 690					APIKey:  "key2",
 691					Disable: true,
 692				},
 693			}),
 694		}
 695
 696		require.False(t, cfg.IsConfigured())
 697	})
 698}
 699
 700func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
 701	cfg := &Config{
 702		Options: &Options{
 703			DisabledTools: []string{},
 704		},
 705	}
 706
 707	cfg.SetupAgents()
 708	coderAgent, ok := cfg.Agents[AgentCoder]
 709	require.True(t, ok)
 710	assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
 711
 712	taskAgent, ok := cfg.Agents[AgentTask]
 713	require.True(t, ok)
 714	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 715}
 716
 717func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 718	cfg := &Config{
 719		Options: &Options{
 720			DisabledTools: []string{
 721				"edit",
 722				"download",
 723				"grep",
 724			},
 725		},
 726	}
 727
 728	cfg.SetupAgents()
 729	coderAgent, ok := cfg.Agents[AgentCoder]
 730	require.True(t, ok)
 731
 732	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)
 733
 734	taskAgent, ok := cfg.Agents[AgentTask]
 735	require.True(t, ok)
 736	assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 737}
 738
 739func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 740	cfg := &Config{
 741		Options: &Options{
 742			DisabledTools: []string{
 743				"glob",
 744				"grep",
 745				"ls",
 746				"sourcegraph",
 747				"view",
 748			},
 749		},
 750	}
 751
 752	cfg.SetupAgents()
 753	coderAgent, ok := cfg.Agents[AgentCoder]
 754	require.True(t, ok)
 755	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)
 756
 757	taskAgent, ok := cfg.Agents[AgentTask]
 758	require.True(t, ok)
 759	assert.Len(t, taskAgent.AllowedTools, 0)
 760}
 761
 762func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
 763	knownProviders := []catwalk.Provider{
 764		{
 765			ID:          "openai",
 766			APIKey:      "$OPENAI_API_KEY",
 767			APIEndpoint: "https://api.openai.com/v1",
 768			Models: []catwalk.Model{{
 769				ID: "test-model",
 770			}},
 771		},
 772	}
 773
 774	cfg := &Config{
 775		Providers: csync.NewMapFrom(map[string]ProviderConfig{
 776			"openai": {
 777				Disable: true,
 778			},
 779		}),
 780	}
 781	cfg.setDefaults("/tmp", "")
 782
 783	env := env.NewFromMap(map[string]string{
 784		"OPENAI_API_KEY": "test-key",
 785	})
 786	resolver := NewShellVariableResolver(env)
 787	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 788	require.NoError(t, err)
 789
 790	require.Equal(t, cfg.Providers.Len(), 1)
 791	prov, exists := cfg.Providers.Get("openai")
 792	require.True(t, exists)
 793	require.True(t, prov.Disable)
 794}
 795
 796func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
 797	t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) {
 798		cfg := &Config{
 799			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 800				"custom": {
 801					BaseURL: "https://api.custom.com/v1",
 802					Models: []catwalk.Model{{
 803						ID: "test-model",
 804					}},
 805				},
 806				"openai": {
 807					APIKey: "$MISSING",
 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(), 1)
 819		_, exists := cfg.Providers.Get("custom")
 820		require.True(t, exists)
 821	})
 822
 823	t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
 824		cfg := &Config{
 825			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 826				"custom": {
 827					APIKey: "test-key",
 828					Models: []catwalk.Model{{
 829						ID: "test-model",
 830					}},
 831				},
 832			}),
 833		}
 834		cfg.setDefaults("/tmp", "")
 835
 836		env := env.NewFromMap(map[string]string{})
 837		resolver := NewShellVariableResolver(env)
 838		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 839		require.NoError(t, err)
 840
 841		require.Equal(t, cfg.Providers.Len(), 0)
 842		_, exists := cfg.Providers.Get("custom")
 843		require.False(t, exists)
 844	})
 845
 846	t.Run("custom provider with no models is removed", func(t *testing.T) {
 847		cfg := &Config{
 848			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 849				"custom": {
 850					APIKey:  "test-key",
 851					BaseURL: "https://api.custom.com/v1",
 852					Models:  []catwalk.Model{},
 853				},
 854			}),
 855		}
 856		cfg.setDefaults("/tmp", "")
 857
 858		env := env.NewFromMap(map[string]string{})
 859		resolver := NewShellVariableResolver(env)
 860		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 861		require.NoError(t, err)
 862
 863		require.Equal(t, cfg.Providers.Len(), 0)
 864		_, exists := cfg.Providers.Get("custom")
 865		require.False(t, exists)
 866	})
 867
 868	t.Run("custom provider with unsupported type is removed", func(t *testing.T) {
 869		cfg := &Config{
 870			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 871				"custom": {
 872					APIKey:  "test-key",
 873					BaseURL: "https://api.custom.com/v1",
 874					Type:    "unsupported",
 875					Models: []catwalk.Model{{
 876						ID: "test-model",
 877					}},
 878				},
 879			}),
 880		}
 881		cfg.setDefaults("/tmp", "")
 882
 883		env := env.NewFromMap(map[string]string{})
 884		resolver := NewShellVariableResolver(env)
 885		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 886		require.NoError(t, err)
 887
 888		require.Equal(t, cfg.Providers.Len(), 0)
 889		_, exists := cfg.Providers.Get("custom")
 890		require.False(t, exists)
 891	})
 892
 893	t.Run("valid custom provider is kept and ID is set", func(t *testing.T) {
 894		cfg := &Config{
 895			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 896				"custom": {
 897					APIKey:  "test-key",
 898					BaseURL: "https://api.custom.com/v1",
 899					Type:    catwalk.TypeOpenAI,
 900					Models: []catwalk.Model{{
 901						ID: "test-model",
 902					}},
 903				},
 904			}),
 905		}
 906		cfg.setDefaults("/tmp", "")
 907
 908		env := env.NewFromMap(map[string]string{})
 909		resolver := NewShellVariableResolver(env)
 910		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 911		require.NoError(t, err)
 912
 913		require.Equal(t, cfg.Providers.Len(), 1)
 914		customProvider, exists := cfg.Providers.Get("custom")
 915		require.True(t, exists)
 916		require.Equal(t, "custom", customProvider.ID)
 917		require.Equal(t, "test-key", customProvider.APIKey)
 918		require.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
 919	})
 920
 921	t.Run("custom anthropic provider is supported", func(t *testing.T) {
 922		cfg := &Config{
 923			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 924				"custom-anthropic": {
 925					APIKey:  "test-key",
 926					BaseURL: "https://api.anthropic.com/v1",
 927					Type:    catwalk.TypeAnthropic,
 928					Models: []catwalk.Model{{
 929						ID: "claude-3-sonnet",
 930					}},
 931				},
 932			}),
 933		}
 934		cfg.setDefaults("/tmp", "")
 935
 936		env := env.NewFromMap(map[string]string{})
 937		resolver := NewShellVariableResolver(env)
 938		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 939		require.NoError(t, err)
 940
 941		require.Equal(t, cfg.Providers.Len(), 1)
 942		customProvider, exists := cfg.Providers.Get("custom-anthropic")
 943		require.True(t, exists)
 944		require.Equal(t, "custom-anthropic", customProvider.ID)
 945		require.Equal(t, "test-key", customProvider.APIKey)
 946		require.Equal(t, "https://api.anthropic.com/v1", customProvider.BaseURL)
 947		require.Equal(t, catwalk.TypeAnthropic, customProvider.Type)
 948	})
 949
 950	t.Run("disabled custom provider is removed", func(t *testing.T) {
 951		cfg := &Config{
 952			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 953				"custom": {
 954					APIKey:  "test-key",
 955					BaseURL: "https://api.custom.com/v1",
 956					Type:    catwalk.TypeOpenAI,
 957					Disable: true,
 958					Models: []catwalk.Model{{
 959						ID: "test-model",
 960					}},
 961				},
 962			}),
 963		}
 964		cfg.setDefaults("/tmp", "")
 965
 966		env := env.NewFromMap(map[string]string{})
 967		resolver := NewShellVariableResolver(env)
 968		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 969		require.NoError(t, err)
 970
 971		require.Equal(t, cfg.Providers.Len(), 0)
 972		_, exists := cfg.Providers.Get("custom")
 973		require.False(t, exists)
 974	})
 975}
 976
 977func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
 978	t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
 979		knownProviders := []catwalk.Provider{
 980			{
 981				ID:          catwalk.InferenceProviderVertexAI,
 982				APIKey:      "",
 983				APIEndpoint: "",
 984				Models: []catwalk.Model{{
 985					ID: "gemini-pro",
 986				}},
 987			},
 988		}
 989
 990		cfg := &Config{
 991			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 992				"vertexai": {
 993					BaseURL: "custom-url",
 994				},
 995			}),
 996		}
 997		cfg.setDefaults("/tmp", "")
 998
 999		env := env.NewFromMap(map[string]string{
1000			"GOOGLE_GENAI_USE_VERTEXAI": "false",
1001		})
1002		resolver := NewShellVariableResolver(env)
1003		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1004		require.NoError(t, err)
1005
1006		require.Equal(t, cfg.Providers.Len(), 0)
1007		_, exists := cfg.Providers.Get("vertexai")
1008		require.False(t, exists)
1009	})
1010
1011	t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
1012		knownProviders := []catwalk.Provider{
1013			{
1014				ID:          catwalk.InferenceProviderBedrock,
1015				APIKey:      "",
1016				APIEndpoint: "",
1017				Models: []catwalk.Model{{
1018					ID: "anthropic.claude-sonnet-4-20250514-v1:0",
1019				}},
1020			},
1021		}
1022
1023		cfg := &Config{
1024			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1025				"bedrock": {
1026					BaseURL: "custom-url",
1027				},
1028			}),
1029		}
1030		cfg.setDefaults("/tmp", "")
1031
1032		env := env.NewFromMap(map[string]string{})
1033		resolver := NewShellVariableResolver(env)
1034		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1035		require.NoError(t, err)
1036
1037		require.Equal(t, cfg.Providers.Len(), 0)
1038		_, exists := cfg.Providers.Get("bedrock")
1039		require.False(t, exists)
1040	})
1041
1042	t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
1043		knownProviders := []catwalk.Provider{
1044			{
1045				ID:          "openai",
1046				APIKey:      "$MISSING_API_KEY",
1047				APIEndpoint: "https://api.openai.com/v1",
1048				Models: []catwalk.Model{{
1049					ID: "test-model",
1050				}},
1051			},
1052		}
1053
1054		cfg := &Config{
1055			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1056				"openai": {
1057					BaseURL: "custom-url",
1058				},
1059			}),
1060		}
1061		cfg.setDefaults("/tmp", "")
1062
1063		env := env.NewFromMap(map[string]string{})
1064		resolver := NewShellVariableResolver(env)
1065		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1066		require.NoError(t, err)
1067
1068		require.Equal(t, cfg.Providers.Len(), 0)
1069		_, exists := cfg.Providers.Get("openai")
1070		require.False(t, exists)
1071	})
1072
1073	t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
1074		knownProviders := []catwalk.Provider{
1075			{
1076				ID:          "openai",
1077				APIKey:      "$OPENAI_API_KEY",
1078				APIEndpoint: "$MISSING_ENDPOINT",
1079				Models: []catwalk.Model{{
1080					ID: "test-model",
1081				}},
1082			},
1083		}
1084
1085		cfg := &Config{
1086			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1087				"openai": {
1088					APIKey: "test-key",
1089				},
1090			}),
1091		}
1092		cfg.setDefaults("/tmp", "")
1093
1094		env := env.NewFromMap(map[string]string{
1095			"OPENAI_API_KEY": "test-key",
1096		})
1097		resolver := NewShellVariableResolver(env)
1098		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1099		require.NoError(t, err)
1100
1101		require.Equal(t, cfg.Providers.Len(), 1)
1102		_, exists := cfg.Providers.Get("openai")
1103		require.True(t, exists)
1104	})
1105}
1106
1107func TestConfig_defaultModelSelection(t *testing.T) {
1108	t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
1109		knownProviders := []catwalk.Provider{
1110			{
1111				ID:                  "openai",
1112				APIKey:              "abc",
1113				DefaultLargeModelID: "large-model",
1114				DefaultSmallModelID: "small-model",
1115				Models: []catwalk.Model{
1116					{
1117						ID:               "large-model",
1118						DefaultMaxTokens: 1000,
1119					},
1120					{
1121						ID:               "small-model",
1122						DefaultMaxTokens: 500,
1123					},
1124				},
1125			},
1126		}
1127
1128		cfg := &Config{}
1129		cfg.setDefaults("/tmp", "")
1130		env := env.NewFromMap(map[string]string{})
1131		resolver := NewShellVariableResolver(env)
1132		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1133		require.NoError(t, err)
1134
1135		large, small, err := cfg.defaultModelSelection(knownProviders)
1136		require.NoError(t, err)
1137		require.Equal(t, "large-model", large.Model)
1138		require.Equal(t, "openai", large.Provider)
1139		require.Equal(t, int64(1000), large.MaxTokens)
1140		require.Equal(t, "small-model", small.Model)
1141		require.Equal(t, "openai", small.Provider)
1142		require.Equal(t, int64(500), small.MaxTokens)
1143	})
1144	t.Run("should error if no providers configured", func(t *testing.T) {
1145		knownProviders := []catwalk.Provider{
1146			{
1147				ID:                  "openai",
1148				APIKey:              "$MISSING_KEY",
1149				DefaultLargeModelID: "large-model",
1150				DefaultSmallModelID: "small-model",
1151				Models: []catwalk.Model{
1152					{
1153						ID:               "large-model",
1154						DefaultMaxTokens: 1000,
1155					},
1156					{
1157						ID:               "small-model",
1158						DefaultMaxTokens: 500,
1159					},
1160				},
1161			},
1162		}
1163
1164		cfg := &Config{}
1165		cfg.setDefaults("/tmp", "")
1166		env := env.NewFromMap(map[string]string{})
1167		resolver := NewShellVariableResolver(env)
1168		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1169		require.NoError(t, err)
1170
1171		_, _, err = cfg.defaultModelSelection(knownProviders)
1172		require.Error(t, err)
1173	})
1174	t.Run("should error if model is missing", func(t *testing.T) {
1175		knownProviders := []catwalk.Provider{
1176			{
1177				ID:                  "openai",
1178				APIKey:              "abc",
1179				DefaultLargeModelID: "large-model",
1180				DefaultSmallModelID: "small-model",
1181				Models: []catwalk.Model{
1182					{
1183						ID:               "not-large-model",
1184						DefaultMaxTokens: 1000,
1185					},
1186					{
1187						ID:               "small-model",
1188						DefaultMaxTokens: 500,
1189					},
1190				},
1191			},
1192		}
1193
1194		cfg := &Config{}
1195		cfg.setDefaults("/tmp", "")
1196		env := env.NewFromMap(map[string]string{})
1197		resolver := NewShellVariableResolver(env)
1198		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1199		require.NoError(t, err)
1200		_, _, err = cfg.defaultModelSelection(knownProviders)
1201		require.Error(t, err)
1202	})
1203
1204	t.Run("should configure the default models with a custom provider", func(t *testing.T) {
1205		knownProviders := []catwalk.Provider{
1206			{
1207				ID:                  "openai",
1208				APIKey:              "$MISSING", // will not be included in the config
1209				DefaultLargeModelID: "large-model",
1210				DefaultSmallModelID: "small-model",
1211				Models: []catwalk.Model{
1212					{
1213						ID:               "not-large-model",
1214						DefaultMaxTokens: 1000,
1215					},
1216					{
1217						ID:               "small-model",
1218						DefaultMaxTokens: 500,
1219					},
1220				},
1221			},
1222		}
1223
1224		cfg := &Config{
1225			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1226				"custom": {
1227					APIKey:  "test-key",
1228					BaseURL: "https://api.custom.com/v1",
1229					Models: []catwalk.Model{
1230						{
1231							ID:               "model",
1232							DefaultMaxTokens: 600,
1233						},
1234					},
1235				},
1236			}),
1237		}
1238		cfg.setDefaults("/tmp", "")
1239		env := env.NewFromMap(map[string]string{})
1240		resolver := NewShellVariableResolver(env)
1241		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1242		require.NoError(t, err)
1243		large, small, err := cfg.defaultModelSelection(knownProviders)
1244		require.NoError(t, err)
1245		require.Equal(t, "model", large.Model)
1246		require.Equal(t, "custom", large.Provider)
1247		require.Equal(t, int64(600), large.MaxTokens)
1248		require.Equal(t, "model", small.Model)
1249		require.Equal(t, "custom", small.Provider)
1250		require.Equal(t, int64(600), small.MaxTokens)
1251	})
1252
1253	t.Run("should fail if no model configured", func(t *testing.T) {
1254		knownProviders := []catwalk.Provider{
1255			{
1256				ID:                  "openai",
1257				APIKey:              "$MISSING", // will not be included in the config
1258				DefaultLargeModelID: "large-model",
1259				DefaultSmallModelID: "small-model",
1260				Models: []catwalk.Model{
1261					{
1262						ID:               "not-large-model",
1263						DefaultMaxTokens: 1000,
1264					},
1265					{
1266						ID:               "small-model",
1267						DefaultMaxTokens: 500,
1268					},
1269				},
1270			},
1271		}
1272
1273		cfg := &Config{
1274			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1275				"custom": {
1276					APIKey:  "test-key",
1277					BaseURL: "https://api.custom.com/v1",
1278					Models:  []catwalk.Model{},
1279				},
1280			}),
1281		}
1282		cfg.setDefaults("/tmp", "")
1283		env := env.NewFromMap(map[string]string{})
1284		resolver := NewShellVariableResolver(env)
1285		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1286		require.NoError(t, err)
1287		_, _, err = cfg.defaultModelSelection(knownProviders)
1288		require.Error(t, err)
1289	})
1290	t.Run("should use the default provider first", func(t *testing.T) {
1291		knownProviders := []catwalk.Provider{
1292			{
1293				ID:                  "openai",
1294				APIKey:              "set",
1295				DefaultLargeModelID: "large-model",
1296				DefaultSmallModelID: "small-model",
1297				Models: []catwalk.Model{
1298					{
1299						ID:               "large-model",
1300						DefaultMaxTokens: 1000,
1301					},
1302					{
1303						ID:               "small-model",
1304						DefaultMaxTokens: 500,
1305					},
1306				},
1307			},
1308		}
1309
1310		cfg := &Config{
1311			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1312				"custom": {
1313					APIKey:  "test-key",
1314					BaseURL: "https://api.custom.com/v1",
1315					Models: []catwalk.Model{
1316						{
1317							ID:               "large-model",
1318							DefaultMaxTokens: 1000,
1319						},
1320					},
1321				},
1322			}),
1323		}
1324		cfg.setDefaults("/tmp", "")
1325		env := env.NewFromMap(map[string]string{})
1326		resolver := NewShellVariableResolver(env)
1327		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1328		require.NoError(t, err)
1329		large, small, err := cfg.defaultModelSelection(knownProviders)
1330		require.NoError(t, err)
1331		require.Equal(t, "large-model", large.Model)
1332		require.Equal(t, "openai", large.Provider)
1333		require.Equal(t, int64(1000), large.MaxTokens)
1334		require.Equal(t, "small-model", small.Model)
1335		require.Equal(t, "openai", small.Provider)
1336		require.Equal(t, int64(500), small.MaxTokens)
1337	})
1338}
1339
1340func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1341	t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) {
1342		knownProviders := []catwalk.Provider{
1343			{
1344				ID:          "openai",
1345				APIKey:      "$OPENAI_API_KEY",
1346				APIEndpoint: "https://api.openai.com/v1",
1347				Models: []catwalk.Model{{
1348					ID: "gpt-4",
1349				}},
1350			},
1351		}
1352
1353		// User references openai but doesn't fully specify it (no base_url, no
1354		// models). This should be rejected because disable_default_providers
1355		// treats all providers as custom.
1356		cfg := &Config{
1357			Options: &Options{
1358				DisableDefaultProviders: true,
1359			},
1360			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1361				"openai": {
1362					APIKey: "$OPENAI_API_KEY",
1363				},
1364			}),
1365		}
1366		cfg.setDefaults("/tmp", "")
1367
1368		env := env.NewFromMap(map[string]string{
1369			"OPENAI_API_KEY": "test-key",
1370		})
1371		resolver := NewShellVariableResolver(env)
1372		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1373		require.ErrorContains(t, err, "no custom providers")
1374
1375		// openai should NOT be present because it lacks base_url and models.
1376		require.Equal(t, 0, cfg.Providers.Len())
1377		_, exists := cfg.Providers.Get("openai")
1378		require.False(t, exists, "openai should not be present without full specification")
1379	})
1380
1381	t.Run("when enabled, fully specified providers work", func(t *testing.T) {
1382		knownProviders := []catwalk.Provider{
1383			{
1384				ID:          "openai",
1385				APIKey:      "$OPENAI_API_KEY",
1386				APIEndpoint: "https://api.openai.com/v1",
1387				Models: []catwalk.Model{{
1388					ID: "gpt-4",
1389				}},
1390			},
1391		}
1392
1393		// User fully specifies their provider.
1394		cfg := &Config{
1395			Options: &Options{
1396				DisableDefaultProviders: true,
1397			},
1398			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1399				"my-llm": {
1400					APIKey:  "$MY_API_KEY",
1401					BaseURL: "https://my-llm.example.com/v1",
1402					Models: []catwalk.Model{{
1403						ID: "my-model",
1404					}},
1405				},
1406			}),
1407		}
1408		cfg.setDefaults("/tmp", "")
1409
1410		env := env.NewFromMap(map[string]string{
1411			"MY_API_KEY":     "test-key",
1412			"OPENAI_API_KEY": "test-key",
1413		})
1414		resolver := NewShellVariableResolver(env)
1415		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1416		require.NoError(t, err)
1417
1418		// Only fully specified provider should be present.
1419		require.Equal(t, 1, cfg.Providers.Len())
1420		provider, exists := cfg.Providers.Get("my-llm")
1421		require.True(t, exists, "my-llm should be present")
1422		require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL)
1423		require.Len(t, provider.Models, 1)
1424
1425		// Default openai should NOT be present.
1426		_, exists = cfg.Providers.Get("openai")
1427		require.False(t, exists, "openai should not be present")
1428	})
1429
1430	t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) {
1431		knownProviders := []catwalk.Provider{
1432			{
1433				ID:          "openai",
1434				APIKey:      "$OPENAI_API_KEY",
1435				APIEndpoint: "https://api.openai.com/v1",
1436				Models: []catwalk.Model{{
1437					ID: "gpt-4",
1438				}},
1439			},
1440			{
1441				ID:          "anthropic",
1442				APIKey:      "$ANTHROPIC_API_KEY",
1443				APIEndpoint: "https://api.anthropic.com/v1",
1444				Models: []catwalk.Model{{
1445					ID: "claude-3",
1446				}},
1447			},
1448		}
1449
1450		// User only configures openai, both API keys are available, but option
1451		// is disabled.
1452		cfg := &Config{
1453			Options: &Options{
1454				DisableDefaultProviders: false,
1455			},
1456			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1457				"openai": {
1458					APIKey: "$OPENAI_API_KEY",
1459				},
1460			}),
1461		}
1462		cfg.setDefaults("/tmp", "")
1463
1464		env := env.NewFromMap(map[string]string{
1465			"OPENAI_API_KEY":    "test-key",
1466			"ANTHROPIC_API_KEY": "test-key",
1467		})
1468		resolver := NewShellVariableResolver(env)
1469		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1470		require.NoError(t, err)
1471
1472		// Both providers should be present.
1473		require.Equal(t, 2, cfg.Providers.Len())
1474		_, exists := cfg.Providers.Get("openai")
1475		require.True(t, exists, "openai should be present")
1476		_, exists = cfg.Providers.Get("anthropic")
1477		require.True(t, exists, "anthropic should be present")
1478	})
1479
1480	t.Run("when enabled, provider missing models is rejected", func(t *testing.T) {
1481		cfg := &Config{
1482			Options: &Options{
1483				DisableDefaultProviders: true,
1484			},
1485			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1486				"my-llm": {
1487					APIKey:  "test-key",
1488					BaseURL: "https://my-llm.example.com/v1",
1489					Models:  []catwalk.Model{}, // No models.
1490				},
1491			}),
1492		}
1493		cfg.setDefaults("/tmp", "")
1494
1495		env := env.NewFromMap(map[string]string{})
1496		resolver := NewShellVariableResolver(env)
1497		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1498		require.ErrorContains(t, err, "no custom providers")
1499
1500		// Provider should be rejected for missing models.
1501		require.Equal(t, 0, cfg.Providers.Len())
1502	})
1503
1504	t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) {
1505		cfg := &Config{
1506			Options: &Options{
1507				DisableDefaultProviders: true,
1508			},
1509			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1510				"my-llm": {
1511					APIKey: "test-key",
1512					Models: []catwalk.Model{{ID: "model"}},
1513					// No BaseURL.
1514				},
1515			}),
1516		}
1517		cfg.setDefaults("/tmp", "")
1518
1519		env := env.NewFromMap(map[string]string{})
1520		resolver := NewShellVariableResolver(env)
1521		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1522		require.ErrorContains(t, err, "no custom providers")
1523
1524		// Provider should be rejected for missing base_url.
1525		require.Equal(t, 0, cfg.Providers.Len())
1526	})
1527}
1528
1529func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) {
1530	t.Run("sets option from environment variable", func(t *testing.T) {
1531		t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true")
1532
1533		cfg := &Config{}
1534		cfg.setDefaults("/tmp", "")
1535
1536		require.True(t, cfg.Options.DisableDefaultProviders)
1537	})
1538
1539	t.Run("does not override when env var is not set", func(t *testing.T) {
1540		cfg := &Config{
1541			Options: &Options{
1542				DisableDefaultProviders: true,
1543			},
1544		}
1545		cfg.setDefaults("/tmp", "")
1546
1547		require.True(t, cfg.Options.DisableDefaultProviders)
1548	})
1549}
1550
1551func TestConfig_configureSelectedModels(t *testing.T) {
1552	t.Run("reload mode should not persist fallback defaults", func(t *testing.T) {
1553		dir := t.TempDir()
1554		globalPath := filepath.Join(dir, "crush.json")
1555		require.NoError(t, os.WriteFile(globalPath, []byte(`{"models":{"large":{"provider":"ghost","model":"missing"}}}`), 0o600))
1556
1557		knownProviders := []catwalk.Provider{
1558			{
1559				ID:                  "openai",
1560				APIKey:              "abc",
1561				DefaultLargeModelID: "large-model",
1562				DefaultSmallModelID: "small-model",
1563				Models: []catwalk.Model{
1564					{ID: "large-model", DefaultMaxTokens: 1000},
1565					{ID: "small-model", DefaultMaxTokens: 500},
1566				},
1567			},
1568		}
1569
1570		cfg := &Config{
1571			Models: map[SelectedModelType]SelectedModel{
1572				SelectedModelTypeLarge: {Provider: "ghost", Model: "missing"},
1573			},
1574		}
1575		cfg.setDefaults(dir, "")
1576		store := &ConfigStore{config: cfg, globalDataPath: globalPath}
1577		env := env.NewFromMap(map[string]string{})
1578		resolver := NewShellVariableResolver(env)
1579		err := cfg.configureProviders(store, env, resolver, knownProviders)
1580		require.NoError(t, err)
1581
1582		err = configureSelectedModels(store, knownProviders, false)
1583		require.NoError(t, err)
1584
1585		// In-memory falls back to default.
1586		require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
1587		require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
1588
1589		// Disk remains unchanged in reload mode.
1590		data, readErr := os.ReadFile(globalPath)
1591		require.NoError(t, readErr)
1592		require.Contains(t, string(data), `"provider":"ghost"`)
1593		require.Contains(t, string(data), `"model":"missing"`)
1594	})
1595	t.Run("should override defaults", func(t *testing.T) {
1596		knownProviders := []catwalk.Provider{
1597			{
1598				ID:                  "openai",
1599				APIKey:              "abc",
1600				DefaultLargeModelID: "large-model",
1601				DefaultSmallModelID: "small-model",
1602				Models: []catwalk.Model{
1603					{
1604						ID:               "larger-model",
1605						DefaultMaxTokens: 2000,
1606					},
1607					{
1608						ID:               "large-model",
1609						DefaultMaxTokens: 1000,
1610					},
1611					{
1612						ID:               "small-model",
1613						DefaultMaxTokens: 500,
1614					},
1615				},
1616			},
1617		}
1618
1619		cfg := &Config{
1620			Models: map[SelectedModelType]SelectedModel{
1621				"large": {
1622					Model: "larger-model",
1623				},
1624			},
1625		}
1626		cfg.setDefaults("/tmp", "")
1627		env := env.NewFromMap(map[string]string{})
1628		resolver := NewShellVariableResolver(env)
1629		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1630		require.NoError(t, err)
1631
1632		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1633		require.NoError(t, err)
1634		large := cfg.Models[SelectedModelTypeLarge]
1635		small := cfg.Models[SelectedModelTypeSmall]
1636		require.Equal(t, "larger-model", large.Model)
1637		require.Equal(t, "openai", large.Provider)
1638		require.Equal(t, int64(2000), large.MaxTokens)
1639		require.Equal(t, "small-model", small.Model)
1640		require.Equal(t, "openai", small.Provider)
1641		require.Equal(t, int64(500), small.MaxTokens)
1642	})
1643	t.Run("should be possible to use multiple providers", func(t *testing.T) {
1644		knownProviders := []catwalk.Provider{
1645			{
1646				ID:                  "openai",
1647				APIKey:              "abc",
1648				DefaultLargeModelID: "large-model",
1649				DefaultSmallModelID: "small-model",
1650				Models: []catwalk.Model{
1651					{
1652						ID:               "large-model",
1653						DefaultMaxTokens: 1000,
1654					},
1655					{
1656						ID:               "small-model",
1657						DefaultMaxTokens: 500,
1658					},
1659				},
1660			},
1661			{
1662				ID:                  "anthropic",
1663				APIKey:              "abc",
1664				DefaultLargeModelID: "a-large-model",
1665				DefaultSmallModelID: "a-small-model",
1666				Models: []catwalk.Model{
1667					{
1668						ID:               "a-large-model",
1669						DefaultMaxTokens: 1000,
1670					},
1671					{
1672						ID:               "a-small-model",
1673						DefaultMaxTokens: 200,
1674					},
1675				},
1676			},
1677		}
1678
1679		cfg := &Config{
1680			Models: map[SelectedModelType]SelectedModel{
1681				"small": {
1682					Model:     "a-small-model",
1683					Provider:  "anthropic",
1684					MaxTokens: 300,
1685				},
1686			},
1687		}
1688		cfg.setDefaults("/tmp", "")
1689		env := env.NewFromMap(map[string]string{})
1690		resolver := NewShellVariableResolver(env)
1691		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1692		require.NoError(t, err)
1693
1694		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1695		require.NoError(t, err)
1696		large := cfg.Models[SelectedModelTypeLarge]
1697		small := cfg.Models[SelectedModelTypeSmall]
1698		require.Equal(t, "large-model", large.Model)
1699		require.Equal(t, "openai", large.Provider)
1700		require.Equal(t, int64(1000), large.MaxTokens)
1701		require.Equal(t, "a-small-model", small.Model)
1702		require.Equal(t, "anthropic", small.Provider)
1703		require.Equal(t, int64(300), small.MaxTokens)
1704	})
1705
1706	t.Run("should override the max tokens only", func(t *testing.T) {
1707		knownProviders := []catwalk.Provider{
1708			{
1709				ID:                  "openai",
1710				APIKey:              "abc",
1711				DefaultLargeModelID: "large-model",
1712				DefaultSmallModelID: "small-model",
1713				Models: []catwalk.Model{
1714					{
1715						ID:               "large-model",
1716						DefaultMaxTokens: 1000,
1717					},
1718					{
1719						ID:               "small-model",
1720						DefaultMaxTokens: 500,
1721					},
1722				},
1723			},
1724		}
1725
1726		cfg := &Config{
1727			Models: map[SelectedModelType]SelectedModel{
1728				"large": {
1729					MaxTokens: 100,
1730				},
1731			},
1732		}
1733		cfg.setDefaults("/tmp", "")
1734		env := env.NewFromMap(map[string]string{})
1735		resolver := NewShellVariableResolver(env)
1736		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1737		require.NoError(t, err)
1738
1739		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1740		require.NoError(t, err)
1741		large := cfg.Models[SelectedModelTypeLarge]
1742		require.Equal(t, "large-model", large.Model)
1743		require.Equal(t, "openai", large.Provider)
1744		require.Equal(t, int64(100), large.MaxTokens)
1745	})
1746}
1747
1748func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {
1749	// Test that HYPER_API_KEY environment variable works without config
1750	knownProviders := []catwalk.Provider{
1751		{
1752			ID:                  "hyper",
1753			APIKey:              "", // No API key in provider definition
1754			DefaultLargeModelID: "large-model",
1755			DefaultSmallModelID: "small-model",
1756			Models: []catwalk.Model{
1757				{
1758					ID:               "large-model",
1759					DefaultMaxTokens: 1000,
1760				},
1761				{
1762					ID:               "small-model",
1763					DefaultMaxTokens: 500,
1764				},
1765			},
1766		},
1767	}
1768
1769	cfg := &Config{}
1770	cfg.setDefaults("/tmp", "")
1771	env := env.NewFromMap(map[string]string{
1772		"HYPER_API_KEY": "env-api-key",
1773	})
1774	resolver := NewShellVariableResolver(env)
1775	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1776	require.NoError(t, err)
1777	require.Equal(t, 1, cfg.Providers.Len())
1778
1779	// Verify Hyper provider is configured with the env var API key
1780	pc, ok := cfg.Providers.Get("hyper")
1781	require.True(t, ok, "Hyper provider should be configured")
1782	require.Equal(t, "env-api-key", pc.APIKey)
1783	require.Equal(t, "env-api-key", pc.APIKeyTemplate)
1784}
1785
1786func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T) {
1787	// Test that config API key takes precedence when HYPER_API_KEY is also set
1788	knownProviders := []catwalk.Provider{
1789		{
1790			ID:                  "hyper",
1791			APIKey:              "provider-api-key",
1792			DefaultLargeModelID: "large-model",
1793			DefaultSmallModelID: "small-model",
1794			Models: []catwalk.Model{
1795				{
1796					ID:               "large-model",
1797					DefaultMaxTokens: 1000,
1798				},
1799				{
1800					ID:               "small-model",
1801					DefaultMaxTokens: 500,
1802				},
1803			},
1804		},
1805	}
1806
1807	// User has Hyper configured with an API key
1808	cfg := &Config{
1809		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1810			"hyper": {
1811				APIKey: "config-api-key",
1812			},
1813		}),
1814	}
1815	cfg.setDefaults("/tmp", "")
1816
1817	// But they also have HYPER_API_KEY set - env var should take precedence
1818	env := env.NewFromMap(map[string]string{
1819		"HYPER_API_KEY": "env-api-key",
1820	})
1821	resolver := NewShellVariableResolver(env)
1822	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1823	require.NoError(t, err)
1824	require.Equal(t, 1, cfg.Providers.Len())
1825
1826	// Verify env var takes precedence (as per requirements)
1827	pc, ok := cfg.Providers.Get("hyper")
1828	require.True(t, ok, "Hyper provider should be configured")
1829	require.Equal(t, "env-api-key", pc.APIKey)
1830}
1831
1832// TestConfig_configureProviders_ProviderHeaderResolveError verifies
1833// that a failing $(cmd) in a provider header fails the provider load
1834// with a clear message that names the offending header. Provider
1835// headers share the MCP error contract.
1836func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
1837	knownProviders := []catwalk.Provider{
1838		{
1839			ID:          "openai",
1840			APIKey:      "$OPENAI_API_KEY",
1841			APIEndpoint: "https://api.openai.com/v1",
1842			Models:      []catwalk.Model{{ID: "test-model"}},
1843		},
1844	}
1845
1846	cfg := &Config{
1847		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1848			"openai": {
1849				ExtraHeaders: map[string]string{
1850					// Failing $(...) — inner command exits 1. Must
1851					// propagate as an error, not a silent truncation.
1852					"X-Broken": "$(false)",
1853				},
1854			},
1855		}),
1856	}
1857	cfg.setDefaults("/tmp", "")
1858
1859	testEnv := env.NewFromMap(map[string]string{
1860		"OPENAI_API_KEY": "test-key",
1861		"PATH":           os.Getenv("PATH"),
1862	})
1863	resolver := NewShellVariableResolver(testEnv)
1864
1865	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1866	require.Error(t, err, "failing $(cmd) in a header must fail the provider load")
1867	require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
1868}
1869
1870// TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads
1871// verifies that a Catwalk-style default header like
1872// "OpenAI-Organization": "$OPENAI_ORG_ID" loads cleanly under lenient
1873// nounset (unset → "" → header dropped), and does not fail the load
1874// or leave the literal template on the wire.
1875func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
1876	knownProviders := []catwalk.Provider{
1877		{
1878			ID:          "openai",
1879			APIKey:      "$OPENAI_API_KEY",
1880			APIEndpoint: "https://api.openai.com/v1",
1881			Models:      []catwalk.Model{{ID: "test-model"}},
1882			DefaultHeaders: map[string]string{
1883				"OpenAI-Organization": "$OPENAI_ORG_ID",
1884			},
1885		},
1886	}
1887
1888	cfg := &Config{}
1889	cfg.setDefaults("/tmp", "")
1890
1891	testEnv := env.NewFromMap(map[string]string{
1892		"OPENAI_API_KEY": "test-key",
1893		"PATH":           os.Getenv("PATH"),
1894	})
1895	resolver := NewShellVariableResolver(testEnv)
1896
1897	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1898	require.NoError(t, err, "optional env-gated header must not fail the load")
1899
1900	pc, ok := cfg.Providers.Get("openai")
1901	require.True(t, ok, "openai provider must still be configured")
1902	_, present := pc.ExtraHeaders["OpenAI-Organization"]
1903	require.False(t, present, "header whose value resolves to empty must be absent")
1904}
1905
1906// TestConfig_configureProviders_LiteralEmptyHeaderDropped pins design
1907// decision #18 for the literal case: a user-authored
1908// "X-Custom": "" in extra_headers is absent from the resolved map.
1909// Applies to both known- and custom-provider paths; this test
1910// exercises the custom-provider loop.
1911func TestConfig_configureProviders_LiteralEmptyHeaderDropped(t *testing.T) {
1912	cfg := &Config{
1913		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1914			"my-llm": {
1915				APIKey:  "test-key",
1916				BaseURL: "https://my-llm.example.com/v1",
1917				Type:    catwalk.TypeOpenAI,
1918				Models:  []catwalk.Model{{ID: "m"}},
1919				ExtraHeaders: map[string]string{
1920					"X-Custom": "",
1921					"X-Kept":   "present",
1922				},
1923			},
1924		}),
1925	}
1926	cfg.setDefaults("/tmp", "")
1927
1928	testEnv := env.NewFromMap(map[string]string{
1929		"PATH": os.Getenv("PATH"),
1930	})
1931	resolver := NewShellVariableResolver(testEnv)
1932
1933	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, []catwalk.Provider{})
1934	require.NoError(t, err)
1935
1936	pc, ok := cfg.Providers.Get("my-llm")
1937	require.True(t, ok)
1938	_, present := pc.ExtraHeaders["X-Custom"]
1939	require.False(t, present, "literal empty-string header must be dropped")
1940	require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1941}
1942
1943// TestConfig_configureProviders_EchoEmptyHeaderDropped pins design
1944// decision #18 for the non-failing empty case: $(echo) exits 0 with
1945// empty output, resolves cleanly to "", and must be dropped the same
1946// way an unset bare $VAR is. Exercises the known-provider loop.
1947func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
1948	knownProviders := []catwalk.Provider{
1949		{
1950			ID:          "openai",
1951			APIKey:      "$OPENAI_API_KEY",
1952			APIEndpoint: "https://api.openai.com/v1",
1953			Models:      []catwalk.Model{{ID: "test-model"}},
1954			DefaultHeaders: map[string]string{
1955				"X-Empty": "$(echo)",
1956				"X-Kept":  "present",
1957			},
1958		},
1959	}
1960
1961	cfg := &Config{}
1962	cfg.setDefaults("/tmp", "")
1963
1964	testEnv := env.NewFromMap(map[string]string{
1965		"OPENAI_API_KEY": "test-key",
1966		"PATH":           os.Getenv("PATH"),
1967	})
1968	resolver := NewShellVariableResolver(testEnv)
1969
1970	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1971	require.NoError(t, err)
1972
1973	pc, ok := cfg.Providers.Get("openai")
1974	require.True(t, ok)
1975	_, present := pc.ExtraHeaders["X-Empty"]
1976	require.False(t, present, "$(echo) → empty → header must be dropped")
1977	require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1978}
1979
1980// TestConfig_configureProviders_UnsetAPIKeySkipsProvider verifies that
1981// under the lenient-nounset shell resolver, $UNSET_API_KEY expands to
1982// ("", nil) rather than ("", err), and the existing
1983// `v == "" || err != nil` skip path at load.go:331 still drops the
1984// provider. The slog.Warn line is emitted on the same
1985// path but is not asserted here — internal/config/load_test.go's
1986// TestMain replaces the default slog handler with an io.Discard
1987// writer, so capturing that log line would require mid-test handler
1988// swapping and a sync.Mutex dance that adds more flake surface than
1989// signal. The observable outcome (provider absent from the map) is
1990// what downstream code — model picker, agent wiring — actually reads,
1991// so that's what we pin.
1992func TestConfig_configureProviders_UnsetAPIKeySkipsProvider(t *testing.T) {
1993	knownProviders := []catwalk.Provider{
1994		{
1995			ID:          "openai",
1996			APIKey:      "$SOMETHING_UNSET",
1997			APIEndpoint: "https://api.openai.com/v1",
1998			Models:      []catwalk.Model{{ID: "test-model"}},
1999		},
2000	}
2001
2002	// Existing user config for this known provider so the load.go:332
2003	// `if configExists` branch fires and actually calls Providers.Del.
2004	// Without it the provider was never in the map to begin with and
2005	// the test would pass trivially.
2006	cfg := &Config{
2007		Providers: csync.NewMapFrom(map[string]ProviderConfig{
2008			"openai": {BaseURL: "custom-url"},
2009		}),
2010	}
2011	cfg.setDefaults("/tmp", "")
2012
2013	testEnv := env.NewFromMap(map[string]string{
2014		"PATH": os.Getenv("PATH"),
2015	})
2016	resolver := NewShellVariableResolver(testEnv)
2017
2018	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2019	require.NoError(t, err, "skip path must not surface as a load error")
2020
2021	require.Equal(t, 0, cfg.Providers.Len(), "provider with unset API key must be skipped")
2022	_, exists := cfg.Providers.Get("openai")
2023	require.False(t, exists)
2024}
2025
2026// TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider pins
2027// that the two failure modes for APIKey — ("", nil) from an unset var
2028// under lenient nounset and ("", err) from a failing $(cmd) — are
2029// equivalent for the skip outcome at load.go:331. The `v == "" ||
2030// err != nil` check fires on either branch; this test locks in that
2031// equivalence so a future refactor that splits the check into two
2032// paths doesn't accidentally start propagating $(false) as a load
2033// error while keeping unset-var as a silent skip (or vice versa).
2034func TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider(t *testing.T) {
2035	knownProviders := []catwalk.Provider{
2036		{
2037			ID:          "openai",
2038			APIKey:      "$(false)",
2039			APIEndpoint: "https://api.openai.com/v1",
2040			Models:      []catwalk.Model{{ID: "test-model"}},
2041		},
2042	}
2043
2044	cfg := &Config{
2045		Providers: csync.NewMapFrom(map[string]ProviderConfig{
2046			"openai": {BaseURL: "custom-url"},
2047		}),
2048	}
2049	cfg.setDefaults("/tmp", "")
2050
2051	testEnv := env.NewFromMap(map[string]string{
2052		"PATH": os.Getenv("PATH"),
2053	})
2054	resolver := NewShellVariableResolver(testEnv)
2055
2056	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2057	require.NoError(t, err, "failing $(cmd) in API key must skip provider, not fail load")
2058
2059	require.Equal(t, 0, cfg.Providers.Len(), "provider with failing $(cmd) API key must be skipped")
2060	_, exists := cfg.Providers.Get("openai")
2061	require.False(t, exists)
2062}
2063
2064// TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider pins
2065// the same contract on the Azure path at load.go:287 — APIEndpoint is
2066// the field that gates Azure and goes through the same
2067// `v == "" || err != nil` skip check. Covered here so both branches
2068// of the shared skip pattern (APIKey default path and APIEndpoint
2069// Azure path) are tested; a future refactor that unifies them can
2070// rely on these two tests to catch drift.
2071func TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider(t *testing.T) {
2072	knownProviders := []catwalk.Provider{
2073		{
2074			ID:          catwalk.InferenceProviderAzure,
2075			APIKey:      "test-key",
2076			APIEndpoint: "$UNSET_AZURE_ENDPOINT",
2077			Models:      []catwalk.Model{{ID: "test-model"}},
2078		},
2079	}
2080
2081	cfg := &Config{
2082		Providers: csync.NewMapFrom(map[string]ProviderConfig{
2083			"azure": {BaseURL: ""},
2084		}),
2085	}
2086	cfg.setDefaults("/tmp", "")
2087
2088	testEnv := env.NewFromMap(map[string]string{
2089		"PATH": os.Getenv("PATH"),
2090	})
2091	resolver := NewShellVariableResolver(testEnv)
2092
2093	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2094	require.NoError(t, err)
2095
2096	require.Equal(t, 0, cfg.Providers.Len(), "azure provider with unset endpoint must be skipped")
2097	_, exists := cfg.Providers.Get("azure")
2098	require.False(t, exists)
2099}