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