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